mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Variables: migrates ad hoc variable type to react/redux. (#22784)
* Refactor: moves all the newVariables part to features/variables directory * Feature: adds datasource type * Tests: adds reducer tests * Tests: covers data source actions with tests * Chore: reduces strict null errors * boilerplate that will be replaced by real code. * added old editor template. * added initial version of ad hoc editor. * added working (apart from add) version of the editor. * Added placeholder for picker. * Have a working UI. Need to connect it so we refresh the variables on changes. * variable should be updated now. * removed console.log * made the url work. * cleaned up the adapter. * added possiblity to create filter directly from table. * moved infotext from general reducer to extended value of adhoc. * fixed strict null errors. * fixed strict null errors. * fixed issue where remove was displayed before being added. * fixed issue with fragment key. * changed so template_src is using the redux variables. * minor refactorings. * moved adhoc picker to adhoc variable. * adding tests for reducer and fixed bug. * added tests or urlparser. * added tests for ad hoc actions. * added more tests. * added more tests. * fixed strict null error. * fixed copy n pase error. * added utilit for getting new variable index. * removed console.log * added location to reducerTester type and created a module type for it. * changed so we only have one builder pattern. * fixed tests to use static expected values. * fixed strict errors. * fixed more strict errors. Co-authored-by: Hugo Häggmark <hugo.haggmark@grafana.com>
This commit is contained in:
parent
6c9d833602
commit
a7a1406415
@ -16,6 +16,7 @@ import { createCustomVariableAdapter } from '../variables/custom/adapter';
|
|||||||
import { createTextBoxVariableAdapter } from '../variables/textbox/adapter';
|
import { createTextBoxVariableAdapter } from '../variables/textbox/adapter';
|
||||||
import { createConstantVariableAdapter } from '../variables/constant/adapter';
|
import { createConstantVariableAdapter } from '../variables/constant/adapter';
|
||||||
import { createDataSourceVariableAdapter } from '../variables/datasource/adapter';
|
import { createDataSourceVariableAdapter } from '../variables/datasource/adapter';
|
||||||
|
import { createAdHocVariableAdapter } from '../variables/adhoc/adapter';
|
||||||
import { createIntervalVariableAdapter } from '../variables/interval/adapter';
|
import { createIntervalVariableAdapter } from '../variables/interval/adapter';
|
||||||
|
|
||||||
coreModule.factory('templateSrv', () => templateSrv);
|
coreModule.factory('templateSrv', () => templateSrv);
|
||||||
@ -36,4 +37,5 @@ variableAdapters.set('custom', createCustomVariableAdapter());
|
|||||||
variableAdapters.set('textbox', createTextBoxVariableAdapter());
|
variableAdapters.set('textbox', createTextBoxVariableAdapter());
|
||||||
variableAdapters.set('constant', createConstantVariableAdapter());
|
variableAdapters.set('constant', createConstantVariableAdapter());
|
||||||
variableAdapters.set('datasource', createDataSourceVariableAdapter());
|
variableAdapters.set('datasource', createDataSourceVariableAdapter());
|
||||||
|
variableAdapters.set('adhoc', createAdHocVariableAdapter());
|
||||||
variableAdapters.set('interval', createIntervalVariableAdapter());
|
variableAdapters.set('interval', createIntervalVariableAdapter());
|
||||||
|
@ -3,9 +3,10 @@ import _ from 'lodash';
|
|||||||
import { variableRegex } from 'app/features/templating/variable';
|
import { variableRegex } from 'app/features/templating/variable';
|
||||||
import { escapeHtml } from 'app/core/utils/text';
|
import { escapeHtml } from 'app/core/utils/text';
|
||||||
import { ScopedVars, TimeRange } from '@grafana/data';
|
import { ScopedVars, TimeRange } from '@grafana/data';
|
||||||
import { getVariableWithName } from '../variables/state/selectors';
|
import { getVariableWithName, getFilteredVariables } from '../variables/state/selectors';
|
||||||
import { getState } from '../../store/store';
|
import { getState } from '../../store/store';
|
||||||
import { getConfig } from 'app/core/config';
|
import { getConfig } from 'app/core/config';
|
||||||
|
import { isAdHoc } from '../variables/guard';
|
||||||
|
|
||||||
function luceneEscape(value: string) {
|
function luceneEscape(value: string) {
|
||||||
return value.replace(/([\!\*\+\-\=<>\s\&\|\(\)\[\]\{\}\^\~\?\:\\/"])/g, '\\$1');
|
return value.replace(/([\!\*\+\-\=<>\s\&\|\(\)\[\]\{\}\^\~\?\:\\/"])/g, '\\$1');
|
||||||
@ -79,20 +80,12 @@ export class TemplateSrv {
|
|||||||
getAdhocFilters(datasourceName: string) {
|
getAdhocFilters(datasourceName: string) {
|
||||||
let filters: any = [];
|
let filters: any = [];
|
||||||
|
|
||||||
if (this.variables) {
|
for (const variable of this.getAdHocVariables()) {
|
||||||
for (let i = 0; i < this.variables.length; i++) {
|
if (variable.datasource === null || variable.datasource === datasourceName) {
|
||||||
const variable = this.variables[i];
|
filters = filters.concat(variable.filters);
|
||||||
if (variable.type !== 'adhoc') {
|
} else if (variable.datasource.indexOf('$') === 0) {
|
||||||
continue;
|
if (this.replace(variable.datasource) === datasourceName) {
|
||||||
}
|
|
||||||
|
|
||||||
// null is the "default" datasource
|
|
||||||
if (variable.datasource === null || variable.datasource === datasourceName) {
|
|
||||||
filters = filters.concat(variable.filters);
|
filters = filters.concat(variable.filters);
|
||||||
} else if (variable.datasource.indexOf('$') === 0) {
|
|
||||||
if (this.replace(variable.datasource) === datasourceName) {
|
|
||||||
filters = filters.concat(variable.filters);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -390,6 +383,16 @@ export class TemplateSrv {
|
|||||||
|
|
||||||
return this.index[name];
|
return this.index[name];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private getAdHocVariables = (): any[] => {
|
||||||
|
if (getConfig().featureToggles.newVariables) {
|
||||||
|
return getFilteredVariables(isAdHoc);
|
||||||
|
}
|
||||||
|
if (Array.isArray(this.variables)) {
|
||||||
|
return this.variables.filter(isAdHoc);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new TemplateSrv();
|
export default new TemplateSrv();
|
||||||
|
85
public/app/features/variables/adhoc/AdHocVariableEditor.tsx
Normal file
85
public/app/features/variables/adhoc/AdHocVariableEditor.tsx
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
import React, { PureComponent } from 'react';
|
||||||
|
import { AdHocVariableModel } from '../../templating/variable';
|
||||||
|
import { VariableEditorProps } from '../editor/types';
|
||||||
|
import { VariableEditorState } from '../editor/reducer';
|
||||||
|
import { AdHocVariableEditorState } from './reducer';
|
||||||
|
import { initAdHocVariableEditor, changeVariableDatasource } from './actions';
|
||||||
|
import { connectWithStore } from 'app/core/utils/connectWithReduxStore';
|
||||||
|
import { MapDispatchToProps, MapStateToProps } from 'react-redux';
|
||||||
|
import { StoreState } from 'app/types';
|
||||||
|
|
||||||
|
export interface OwnProps extends VariableEditorProps<AdHocVariableModel> {}
|
||||||
|
|
||||||
|
interface ConnectedProps {
|
||||||
|
editor: VariableEditorState<AdHocVariableEditorState>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DispatchProps {
|
||||||
|
initAdHocVariableEditor: typeof initAdHocVariableEditor;
|
||||||
|
changeVariableDatasource: typeof changeVariableDatasource;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = OwnProps & ConnectedProps & DispatchProps;
|
||||||
|
|
||||||
|
export class AdHocVariableEditorUnConnected extends PureComponent<Props> {
|
||||||
|
componentDidMount() {
|
||||||
|
this.props.initAdHocVariableEditor();
|
||||||
|
}
|
||||||
|
|
||||||
|
onDatasourceChanged = (event: React.ChangeEvent<HTMLSelectElement>) => {
|
||||||
|
this.props.changeVariableDatasource(event.target.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { variable, editor } = this.props;
|
||||||
|
const dataSources = editor.extended?.dataSources ?? [];
|
||||||
|
const infoText = editor.extended?.infoText ?? null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="gf-form-group">
|
||||||
|
<h5 className="section-heading">Options</h5>
|
||||||
|
<div className="gf-form max-width-21">
|
||||||
|
<span className="gf-form-label width-8">Data source</span>
|
||||||
|
<div className="gf-form-select-wrapper max-width-14">
|
||||||
|
<select
|
||||||
|
className="gf-form-input"
|
||||||
|
required
|
||||||
|
onChange={this.onDatasourceChanged}
|
||||||
|
value={variable.datasource ?? ''}
|
||||||
|
aria-label="Variable editor Form AdHoc DataSource select"
|
||||||
|
>
|
||||||
|
{dataSources.map(ds => (
|
||||||
|
<option key={ds.value ?? ''} value={ds.value ?? ''} label={ds.text}>
|
||||||
|
{ds.text}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{infoText && (
|
||||||
|
<div className="alert alert-info gf-form-group" aria-label="Variable editor Form Alert">
|
||||||
|
{infoText}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapStateToProps: MapStateToProps<ConnectedProps, OwnProps, StoreState> = (state, ownProps) => ({
|
||||||
|
editor: state.templating.editor as VariableEditorState<AdHocVariableEditorState>,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mapDispatchToProps: MapDispatchToProps<DispatchProps, OwnProps> = {
|
||||||
|
initAdHocVariableEditor,
|
||||||
|
changeVariableDatasource,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AdHocVariableEditor = connectWithStore(
|
||||||
|
AdHocVariableEditorUnConnected,
|
||||||
|
mapStateToProps,
|
||||||
|
mapDispatchToProps
|
||||||
|
);
|
577
public/app/features/variables/adhoc/actions.test.ts
Normal file
577
public/app/features/variables/adhoc/actions.test.ts
Normal file
@ -0,0 +1,577 @@
|
|||||||
|
import { variableAdapters } from '../adapters';
|
||||||
|
import { createAdHocVariableAdapter } from './adapter';
|
||||||
|
import { reduxTester } from '../../../../test/core/redux/reduxTester';
|
||||||
|
import { TemplatingState } from 'app/features/variables/state/reducers';
|
||||||
|
import { getRootReducer } from '../state/helpers';
|
||||||
|
import { toVariablePayload, toVariableIdentifier } from '../state/types';
|
||||||
|
import * as variableBuilder from '../shared/testing/builders';
|
||||||
|
import {
|
||||||
|
applyFilterFromTable,
|
||||||
|
AdHocTableOptions,
|
||||||
|
changeFilter,
|
||||||
|
addFilter,
|
||||||
|
removeFilter,
|
||||||
|
setFiltersFromUrl,
|
||||||
|
initAdHocVariableEditor,
|
||||||
|
changeVariableDatasource,
|
||||||
|
} from './actions';
|
||||||
|
import { filterAdded, filterUpdated, filterRemoved, filtersRestored } from './reducer';
|
||||||
|
import { addVariable, changeVariableProp } from '../state/sharedReducer';
|
||||||
|
import { updateLocation } from 'app/core/actions';
|
||||||
|
import { DashboardState, LocationState } from 'app/types';
|
||||||
|
import { VariableModel } from 'app/features/templating/variable';
|
||||||
|
import { changeVariableEditorExtended, setIdInEditor } from '../editor/reducer';
|
||||||
|
import { DataSourceSelectItem, DataSourcePluginMeta } from '@grafana/data';
|
||||||
|
|
||||||
|
const uuid = '0';
|
||||||
|
const getMetricSources = jest.fn().mockReturnValue([]);
|
||||||
|
const getDatasource = jest.fn().mockResolvedValue({});
|
||||||
|
|
||||||
|
jest.mock('uuid', () => ({
|
||||||
|
v4: jest.fn(() => uuid),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('app/features/plugins/datasource_srv', () => ({
|
||||||
|
getDatasourceSrv: jest.fn(() => ({
|
||||||
|
get: getDatasource,
|
||||||
|
getMetricSources,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
type ReducersUsedInContext = {
|
||||||
|
templating: TemplatingState;
|
||||||
|
dashboard: DashboardState;
|
||||||
|
location: LocationState;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('adhoc actions', () => {
|
||||||
|
variableAdapters.set('adhoc', createAdHocVariableAdapter());
|
||||||
|
|
||||||
|
describe('when applyFilterFromTable is dispatched and filter already exist', () => {
|
||||||
|
it('then correct actions are dispatched', async () => {
|
||||||
|
const options: AdHocTableOptions = {
|
||||||
|
datasource: 'influxdb',
|
||||||
|
key: 'filter-key',
|
||||||
|
value: 'filter-value',
|
||||||
|
operator: '=',
|
||||||
|
};
|
||||||
|
|
||||||
|
const existingFilter = {
|
||||||
|
key: 'filter-key',
|
||||||
|
value: 'filter-existing',
|
||||||
|
operator: '!=',
|
||||||
|
condition: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const variable = variableBuilder
|
||||||
|
.adHoc()
|
||||||
|
.withName('Filters')
|
||||||
|
.withFilters([existingFilter])
|
||||||
|
.withUUID(uuid)
|
||||||
|
.withDatasource(options.datasource)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
const tester = await reduxTester<ReducersUsedInContext>()
|
||||||
|
.givenRootReducer(getRootReducer())
|
||||||
|
.whenActionIsDispatched(createAddVariableAction(variable))
|
||||||
|
.whenAsyncActionIsDispatched(applyFilterFromTable(options), true);
|
||||||
|
|
||||||
|
const expectedQuery = { 'var-Filters': ['filter-key|!=|filter-existing', 'filter-key|=|filter-value'] };
|
||||||
|
const expectedFilter = { key: 'filter-key', value: 'filter-value', operator: '=', condition: '' };
|
||||||
|
|
||||||
|
tester.thenDispatchedActionsPredicateShouldEqual(actions => {
|
||||||
|
const [addFilterAction, updateLocationAction] = actions;
|
||||||
|
const expectedNumberOfActions = 2;
|
||||||
|
|
||||||
|
expect(addFilterAction).toEqual(filterAdded(toVariablePayload(variable, expectedFilter)));
|
||||||
|
expect(updateLocationAction).toEqual(updateLocation({ query: expectedQuery }));
|
||||||
|
|
||||||
|
return actions.length === expectedNumberOfActions;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when applyFilterFromTable is dispatched and previously no variable or filter exists', () => {
|
||||||
|
it('then correct actions are dispatched', async () => {
|
||||||
|
const options: AdHocTableOptions = {
|
||||||
|
datasource: 'influxdb',
|
||||||
|
key: 'filter-key',
|
||||||
|
value: 'filter-value',
|
||||||
|
operator: '=',
|
||||||
|
};
|
||||||
|
|
||||||
|
const tester = await reduxTester<ReducersUsedInContext>()
|
||||||
|
.givenRootReducer(getRootReducer())
|
||||||
|
.whenAsyncActionIsDispatched(applyFilterFromTable(options), true);
|
||||||
|
|
||||||
|
const variable = variableBuilder
|
||||||
|
.adHoc()
|
||||||
|
.withName('Filters')
|
||||||
|
.withUUID(uuid)
|
||||||
|
.withDatasource(options.datasource)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
const expectedQuery = { 'var-Filters': ['filter-key|=|filter-value'] };
|
||||||
|
const expectedFilter = { key: 'filter-key', value: 'filter-value', operator: '=', condition: '' };
|
||||||
|
|
||||||
|
tester.thenDispatchedActionsPredicateShouldEqual(actions => {
|
||||||
|
const [addVariableAction, addFilterAction, updateLocationAction] = actions;
|
||||||
|
const expectedNumberOfActions = 3;
|
||||||
|
|
||||||
|
expect(addVariableAction).toEqual(createAddVariableAction(variable));
|
||||||
|
expect(addFilterAction).toEqual(filterAdded(toVariablePayload(variable, expectedFilter)));
|
||||||
|
expect(updateLocationAction).toEqual(updateLocation({ query: expectedQuery }));
|
||||||
|
|
||||||
|
return actions.length === expectedNumberOfActions;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when applyFilterFromTable is dispatched and previously no filter exists', () => {
|
||||||
|
it('then correct actions are dispatched', async () => {
|
||||||
|
const options: AdHocTableOptions = {
|
||||||
|
datasource: 'influxdb',
|
||||||
|
key: 'filter-key',
|
||||||
|
value: 'filter-value',
|
||||||
|
operator: '=',
|
||||||
|
};
|
||||||
|
|
||||||
|
const variable = variableBuilder
|
||||||
|
.adHoc()
|
||||||
|
.withName('Filters')
|
||||||
|
.withUUID(uuid)
|
||||||
|
.withFilters([])
|
||||||
|
.withDatasource(options.datasource)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
const tester = await reduxTester<ReducersUsedInContext>()
|
||||||
|
.givenRootReducer(getRootReducer())
|
||||||
|
.whenActionIsDispatched(createAddVariableAction(variable))
|
||||||
|
.whenAsyncActionIsDispatched(applyFilterFromTable(options), true);
|
||||||
|
|
||||||
|
const expectedFilter = { key: 'filter-key', value: 'filter-value', operator: '=', condition: '' };
|
||||||
|
const expectedQuery = { 'var-Filters': ['filter-key|=|filter-value'] };
|
||||||
|
|
||||||
|
tester.thenDispatchedActionsPredicateShouldEqual(actions => {
|
||||||
|
const [addFilterAction, updateLocationAction] = actions;
|
||||||
|
const expectedNumberOfActions = 2;
|
||||||
|
|
||||||
|
expect(addFilterAction).toEqual(filterAdded(toVariablePayload(variable, expectedFilter)));
|
||||||
|
expect(updateLocationAction).toEqual(updateLocation({ query: expectedQuery }));
|
||||||
|
|
||||||
|
return actions.length === expectedNumberOfActions;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when applyFilterFromTable is dispatched and adhoc variable with other datasource exists', () => {
|
||||||
|
it('then correct actions are dispatched', async () => {
|
||||||
|
const options: AdHocTableOptions = {
|
||||||
|
datasource: 'influxdb',
|
||||||
|
key: 'filter-key',
|
||||||
|
value: 'filter-value',
|
||||||
|
operator: '=',
|
||||||
|
};
|
||||||
|
|
||||||
|
const existing = variableBuilder
|
||||||
|
.adHoc()
|
||||||
|
.withName('elastic-filter')
|
||||||
|
.withDatasource('elasticsearch')
|
||||||
|
.build();
|
||||||
|
|
||||||
|
const variable = variableBuilder
|
||||||
|
.adHoc()
|
||||||
|
.withName('Filters')
|
||||||
|
.withUUID(uuid)
|
||||||
|
.withDatasource(options.datasource)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
const tester = await reduxTester<ReducersUsedInContext>()
|
||||||
|
.givenRootReducer(getRootReducer())
|
||||||
|
.whenActionIsDispatched(createAddVariableAction(existing))
|
||||||
|
.whenAsyncActionIsDispatched(applyFilterFromTable(options), true);
|
||||||
|
|
||||||
|
const expectedFilter = { key: 'filter-key', value: 'filter-value', operator: '=', condition: '' };
|
||||||
|
const expectedQuery = { 'var-Filters': ['filter-key|=|filter-value'] };
|
||||||
|
|
||||||
|
tester.thenDispatchedActionsPredicateShouldEqual(actions => {
|
||||||
|
const [addVariableAction, addFilterAction, updateLocationAction] = actions;
|
||||||
|
const expectedNumberOfActions = 3;
|
||||||
|
|
||||||
|
expect(addVariableAction).toEqual(createAddVariableAction(variable, 1));
|
||||||
|
expect(addFilterAction).toEqual(filterAdded(toVariablePayload(variable, expectedFilter)));
|
||||||
|
expect(updateLocationAction).toEqual(updateLocation({ query: expectedQuery }));
|
||||||
|
|
||||||
|
return actions.length === expectedNumberOfActions;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when changeFilter is dispatched', () => {
|
||||||
|
it('then correct actions are dispatched', async () => {
|
||||||
|
const existing = {
|
||||||
|
key: 'key',
|
||||||
|
value: 'value',
|
||||||
|
operator: '=',
|
||||||
|
condition: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const updated = {
|
||||||
|
...existing,
|
||||||
|
operator: '!=',
|
||||||
|
};
|
||||||
|
|
||||||
|
const variable = variableBuilder
|
||||||
|
.adHoc()
|
||||||
|
.withUUID(uuid)
|
||||||
|
.withFilters([existing])
|
||||||
|
.withName('elastic-filter')
|
||||||
|
.withDatasource('elasticsearch')
|
||||||
|
.build();
|
||||||
|
|
||||||
|
const update = { index: 0, filter: updated };
|
||||||
|
|
||||||
|
const tester = await reduxTester<ReducersUsedInContext>()
|
||||||
|
.givenRootReducer(getRootReducer())
|
||||||
|
.whenActionIsDispatched(createAddVariableAction(variable))
|
||||||
|
.whenAsyncActionIsDispatched(changeFilter(uuid, update), true);
|
||||||
|
|
||||||
|
const expectedQuery = { 'var-elastic-filter': ['key|!=|value'] };
|
||||||
|
const expectedUpdate = { index: 0, filter: updated };
|
||||||
|
|
||||||
|
tester.thenDispatchedActionsPredicateShouldEqual(actions => {
|
||||||
|
const [filterUpdatedAction, updateLocationAction] = actions;
|
||||||
|
const expectedNumberOfActions = 2;
|
||||||
|
|
||||||
|
expect(filterUpdatedAction).toEqual(filterUpdated(toVariablePayload(variable, expectedUpdate)));
|
||||||
|
expect(updateLocationAction).toEqual(updateLocation({ query: expectedQuery }));
|
||||||
|
|
||||||
|
return actions.length === expectedNumberOfActions;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when addFilter is dispatched on variable with existing filter', () => {
|
||||||
|
it('then correct actions are dispatched', async () => {
|
||||||
|
const existing = {
|
||||||
|
key: 'key',
|
||||||
|
value: 'value',
|
||||||
|
operator: '=',
|
||||||
|
condition: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const adding = {
|
||||||
|
...existing,
|
||||||
|
operator: '!=',
|
||||||
|
};
|
||||||
|
|
||||||
|
const variable = variableBuilder
|
||||||
|
.adHoc()
|
||||||
|
.withUUID(uuid)
|
||||||
|
.withFilters([existing])
|
||||||
|
.withName('elastic-filter')
|
||||||
|
.withDatasource('elasticsearch')
|
||||||
|
.build();
|
||||||
|
|
||||||
|
const tester = await reduxTester<ReducersUsedInContext>()
|
||||||
|
.givenRootReducer(getRootReducer())
|
||||||
|
.whenActionIsDispatched(createAddVariableAction(variable))
|
||||||
|
.whenAsyncActionIsDispatched(addFilter(uuid, adding), true);
|
||||||
|
|
||||||
|
const expectedQuery = { 'var-elastic-filter': ['key|=|value', 'key|!=|value'] };
|
||||||
|
const expectedFilter = { key: 'key', value: 'value', operator: '!=', condition: '' };
|
||||||
|
|
||||||
|
tester.thenDispatchedActionsPredicateShouldEqual(actions => {
|
||||||
|
const [filterAddAction, updateLocationAction] = actions;
|
||||||
|
const expectedNumberOfActions = 2;
|
||||||
|
|
||||||
|
expect(filterAddAction).toEqual(filterAdded(toVariablePayload(variable, expectedFilter)));
|
||||||
|
expect(updateLocationAction).toEqual(updateLocation({ query: expectedQuery }));
|
||||||
|
|
||||||
|
return actions.length === expectedNumberOfActions;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when addFilter is dispatched on variable with no existing filter', () => {
|
||||||
|
it('then correct actions are dispatched', async () => {
|
||||||
|
const adding = {
|
||||||
|
key: 'key',
|
||||||
|
value: 'value',
|
||||||
|
operator: '=',
|
||||||
|
condition: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const variable = variableBuilder
|
||||||
|
.adHoc()
|
||||||
|
.withUUID(uuid)
|
||||||
|
.withFilters([])
|
||||||
|
.withName('elastic-filter')
|
||||||
|
.withDatasource('elasticsearch')
|
||||||
|
.build();
|
||||||
|
|
||||||
|
const tester = await reduxTester<ReducersUsedInContext>()
|
||||||
|
.givenRootReducer(getRootReducer())
|
||||||
|
.whenActionIsDispatched(createAddVariableAction(variable))
|
||||||
|
.whenAsyncActionIsDispatched(addFilter(uuid, adding), true);
|
||||||
|
|
||||||
|
const expectedQuery = { 'var-elastic-filter': ['key|=|value'] };
|
||||||
|
|
||||||
|
tester.thenDispatchedActionsPredicateShouldEqual(actions => {
|
||||||
|
const [filterAddAction, updateLocationAction] = actions;
|
||||||
|
const expectedNumberOfActions = 2;
|
||||||
|
|
||||||
|
expect(filterAddAction).toEqual(filterAdded(toVariablePayload(variable, adding)));
|
||||||
|
expect(updateLocationAction).toEqual(updateLocation({ query: expectedQuery }));
|
||||||
|
|
||||||
|
return actions.length === expectedNumberOfActions;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when removeFilter is dispatched on variable with no existing filter', () => {
|
||||||
|
it('then correct actions are dispatched', async () => {
|
||||||
|
const variable = variableBuilder
|
||||||
|
.adHoc()
|
||||||
|
.withUUID(uuid)
|
||||||
|
.withFilters([])
|
||||||
|
.withName('elastic-filter')
|
||||||
|
.withDatasource('elasticsearch')
|
||||||
|
.build();
|
||||||
|
|
||||||
|
const tester = await reduxTester<ReducersUsedInContext>()
|
||||||
|
.givenRootReducer(getRootReducer())
|
||||||
|
.whenActionIsDispatched(createAddVariableAction(variable))
|
||||||
|
.whenAsyncActionIsDispatched(removeFilter(uuid, 0), true);
|
||||||
|
|
||||||
|
const expectedQuery = { 'var-elastic-filter': [] as string[] };
|
||||||
|
|
||||||
|
tester.thenDispatchedActionsPredicateShouldEqual(actions => {
|
||||||
|
const [filterRemoveAction, updateLocationAction] = actions;
|
||||||
|
const expectedNumberOfActions = 2;
|
||||||
|
|
||||||
|
expect(filterRemoveAction).toEqual(filterRemoved(toVariablePayload(variable, 0)));
|
||||||
|
expect(updateLocationAction).toEqual(updateLocation({ query: expectedQuery }));
|
||||||
|
|
||||||
|
return actions.length === expectedNumberOfActions;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when removeFilter is dispatched on variable with existing filter', () => {
|
||||||
|
it('then correct actions are dispatched', async () => {
|
||||||
|
const filter = {
|
||||||
|
key: 'key',
|
||||||
|
value: 'value',
|
||||||
|
operator: '=',
|
||||||
|
condition: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const variable = variableBuilder
|
||||||
|
.adHoc()
|
||||||
|
.withUUID(uuid)
|
||||||
|
.withFilters([filter])
|
||||||
|
.withName('elastic-filter')
|
||||||
|
.withDatasource('elasticsearch')
|
||||||
|
.build();
|
||||||
|
|
||||||
|
const tester = await reduxTester<ReducersUsedInContext>()
|
||||||
|
.givenRootReducer(getRootReducer())
|
||||||
|
.whenActionIsDispatched(createAddVariableAction(variable))
|
||||||
|
.whenAsyncActionIsDispatched(removeFilter(uuid, 0), true);
|
||||||
|
|
||||||
|
const expectedQuery = { 'var-elastic-filter': [] as string[] };
|
||||||
|
|
||||||
|
tester.thenDispatchedActionsPredicateShouldEqual(actions => {
|
||||||
|
const [filterRemoveAction, updateLocationAction] = actions;
|
||||||
|
const expectedNumberOfActions = 2;
|
||||||
|
|
||||||
|
expect(filterRemoveAction).toEqual(filterRemoved(toVariablePayload(variable, 0)));
|
||||||
|
expect(updateLocationAction).toEqual(updateLocation({ query: expectedQuery }));
|
||||||
|
|
||||||
|
return actions.length === expectedNumberOfActions;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when setFiltersFromUrl is dispatched', () => {
|
||||||
|
it('then correct actions are dispatched', async () => {
|
||||||
|
const existing = {
|
||||||
|
key: 'key',
|
||||||
|
value: 'value',
|
||||||
|
operator: '=',
|
||||||
|
condition: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const variable = variableBuilder
|
||||||
|
.adHoc()
|
||||||
|
.withUUID(uuid)
|
||||||
|
.withFilters([existing])
|
||||||
|
.withName('elastic-filter')
|
||||||
|
.withDatasource('elasticsearch')
|
||||||
|
.build();
|
||||||
|
|
||||||
|
const fromUrl = [
|
||||||
|
{ ...existing, condition: '>' },
|
||||||
|
{ ...existing, name: 'value-2' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const tester = await reduxTester<ReducersUsedInContext>()
|
||||||
|
.givenRootReducer(getRootReducer())
|
||||||
|
.whenActionIsDispatched(createAddVariableAction(variable))
|
||||||
|
.whenAsyncActionIsDispatched(setFiltersFromUrl(uuid, fromUrl), true);
|
||||||
|
|
||||||
|
const expectedQuery = { 'var-elastic-filter': ['key|=|value', 'key|=|value'] };
|
||||||
|
const expectedFilters = [
|
||||||
|
{ key: 'key', value: 'value', operator: '=', condition: '>' },
|
||||||
|
{ key: 'key', value: 'value', operator: '=', condition: '', name: 'value-2' },
|
||||||
|
];
|
||||||
|
|
||||||
|
tester.thenDispatchedActionsPredicateShouldEqual(actions => {
|
||||||
|
const [filterRestoredAction, updateLocationAction] = actions;
|
||||||
|
const expectedNumberOfActions = 2;
|
||||||
|
|
||||||
|
expect(filterRestoredAction).toEqual(filtersRestored(toVariablePayload(variable, expectedFilters)));
|
||||||
|
expect(updateLocationAction).toEqual(updateLocation({ query: expectedQuery }));
|
||||||
|
|
||||||
|
return actions.length === expectedNumberOfActions;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when initAdHocVariableEditor is dispatched', () => {
|
||||||
|
it('then correct actions are dispatched', async () => {
|
||||||
|
const datasources = [
|
||||||
|
createDatasource('elasticsearch-v1'),
|
||||||
|
createDatasource('loki', false),
|
||||||
|
createDatasource('influx'),
|
||||||
|
createDatasource('google-sheets', false),
|
||||||
|
createDatasource('elasticsearch-v7'),
|
||||||
|
];
|
||||||
|
|
||||||
|
getMetricSources.mockRestore();
|
||||||
|
getMetricSources.mockReturnValue(datasources);
|
||||||
|
|
||||||
|
const tester = reduxTester<ReducersUsedInContext>()
|
||||||
|
.givenRootReducer(getRootReducer())
|
||||||
|
.whenActionIsDispatched(initAdHocVariableEditor());
|
||||||
|
|
||||||
|
const expectedDatasources = [
|
||||||
|
{ text: '', value: '' },
|
||||||
|
{ text: 'elasticsearch-v1', value: 'elasticsearch-v1' },
|
||||||
|
{ text: 'influx', value: 'influx' },
|
||||||
|
{ text: 'elasticsearch-v7', value: 'elasticsearch-v7' },
|
||||||
|
];
|
||||||
|
|
||||||
|
tester.thenDispatchedActionsPredicateShouldEqual(actions => {
|
||||||
|
const [changeEditorAction] = actions;
|
||||||
|
const expectedNumberOfActions = 1;
|
||||||
|
|
||||||
|
expect(changeEditorAction).toEqual(
|
||||||
|
changeVariableEditorExtended({
|
||||||
|
propName: 'dataSources',
|
||||||
|
propValue: expectedDatasources,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return actions.length === expectedNumberOfActions;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when changeVariableDatasource is dispatched with unsupported datasource', () => {
|
||||||
|
it('then correct actions are dispatched', async () => {
|
||||||
|
const datasource = 'mysql';
|
||||||
|
const loadingText = 'Adhoc filters are applied automatically to all queries that target this datasource';
|
||||||
|
const variable = variableBuilder
|
||||||
|
.adHoc()
|
||||||
|
.withUUID(uuid)
|
||||||
|
.withDatasource('influxdb')
|
||||||
|
.build();
|
||||||
|
|
||||||
|
getDatasource.mockRestore();
|
||||||
|
getDatasource.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const tester = await reduxTester<ReducersUsedInContext>()
|
||||||
|
.givenRootReducer(getRootReducer())
|
||||||
|
.whenActionIsDispatched(createAddVariableAction(variable))
|
||||||
|
.whenActionIsDispatched(setIdInEditor({ id: variable.uuid! }))
|
||||||
|
.whenAsyncActionIsDispatched(changeVariableDatasource(datasource), true);
|
||||||
|
|
||||||
|
tester.thenDispatchedActionsPredicateShouldEqual(actions => {
|
||||||
|
const [loadingTextAction, changePropAction, unsupportedTextAction] = actions;
|
||||||
|
const expectedNumberOfActions = 3;
|
||||||
|
|
||||||
|
expect(loadingTextAction).toEqual(
|
||||||
|
changeVariableEditorExtended({ propName: 'infoText', propValue: loadingText })
|
||||||
|
);
|
||||||
|
expect(changePropAction).toEqual(
|
||||||
|
changeVariableProp(toVariablePayload(variable, { propName: 'datasource', propValue: datasource }))
|
||||||
|
);
|
||||||
|
expect(unsupportedTextAction).toEqual(
|
||||||
|
changeVariableEditorExtended({
|
||||||
|
propName: 'infoText',
|
||||||
|
propValue: 'This datasource does not support adhoc filters yet.',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return actions.length === expectedNumberOfActions;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when changeVariableDatasource is dispatched with datasource', () => {
|
||||||
|
it('then correct actions are dispatched', async () => {
|
||||||
|
const datasource = 'elasticsearch';
|
||||||
|
const loadingText = 'Adhoc filters are applied automatically to all queries that target this datasource';
|
||||||
|
const variable = variableBuilder
|
||||||
|
.adHoc()
|
||||||
|
.withUUID(uuid)
|
||||||
|
.withDatasource('influxdb')
|
||||||
|
.build();
|
||||||
|
|
||||||
|
getDatasource.mockRestore();
|
||||||
|
getDatasource.mockResolvedValue({
|
||||||
|
getTagKeys: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const tester = await reduxTester<ReducersUsedInContext>()
|
||||||
|
.givenRootReducer(getRootReducer())
|
||||||
|
.whenActionIsDispatched(createAddVariableAction(variable))
|
||||||
|
.whenActionIsDispatched(setIdInEditor({ id: variable.uuid! }))
|
||||||
|
.whenAsyncActionIsDispatched(changeVariableDatasource(datasource), true);
|
||||||
|
|
||||||
|
tester.thenDispatchedActionsPredicateShouldEqual(actions => {
|
||||||
|
const [loadingTextAction, changePropAction] = actions;
|
||||||
|
const expectedNumberOfActions = 2;
|
||||||
|
|
||||||
|
expect(loadingTextAction).toEqual(
|
||||||
|
changeVariableEditorExtended({ propName: 'infoText', propValue: loadingText })
|
||||||
|
);
|
||||||
|
expect(changePropAction).toEqual(
|
||||||
|
changeVariableProp(toVariablePayload(variable, { propName: 'datasource', propValue: datasource }))
|
||||||
|
);
|
||||||
|
|
||||||
|
return actions.length === expectedNumberOfActions;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function createAddVariableAction(variable: VariableModel, index = 0) {
|
||||||
|
const identifier = toVariableIdentifier(variable);
|
||||||
|
const global = false;
|
||||||
|
const data = { global, index, model: { ...variable, index: -1, global } };
|
||||||
|
return addVariable(toVariablePayload(identifier, data));
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDatasource(name: string, selectable = true): DataSourceSelectItem {
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
value: name,
|
||||||
|
meta: {
|
||||||
|
mixed: !selectable,
|
||||||
|
} as DataSourcePluginMeta,
|
||||||
|
sort: '',
|
||||||
|
};
|
||||||
|
}
|
163
public/app/features/variables/adhoc/actions.ts
Normal file
163
public/app/features/variables/adhoc/actions.ts
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
import { v4 } from 'uuid';
|
||||||
|
import { cloneDeep } from 'lodash';
|
||||||
|
import { ThunkResult, StoreState } from 'app/types';
|
||||||
|
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
|
||||||
|
import { changeVariableEditorExtended } from '../editor/reducer';
|
||||||
|
import { changeVariableProp, addVariable } from '../state/sharedReducer';
|
||||||
|
import { getVariable, getNewVariabelIndex } from '../state/selectors';
|
||||||
|
import { toVariablePayload, toVariableIdentifier, AddVariable, VariableIdentifier } from '../state/types';
|
||||||
|
import {
|
||||||
|
AdHocVariabelFilterUpdate,
|
||||||
|
filterRemoved,
|
||||||
|
filterUpdated,
|
||||||
|
filterAdded,
|
||||||
|
filtersRestored,
|
||||||
|
initialAdHocVariableModelState,
|
||||||
|
} from './reducer';
|
||||||
|
import { AdHocVariableFilter, AdHocVariableModel } from 'app/features/templating/variable';
|
||||||
|
import { variableUpdated } from '../state/actions';
|
||||||
|
import { isAdHoc } from '../guard';
|
||||||
|
|
||||||
|
export interface AdHocTableOptions {
|
||||||
|
datasource: string;
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
operator: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filterTableName = 'Filters';
|
||||||
|
|
||||||
|
export const applyFilterFromTable = (options: AdHocTableOptions): ThunkResult<void> => {
|
||||||
|
return async (dispatch, getState) => {
|
||||||
|
let variable = getVariableByOptions(options, getState());
|
||||||
|
|
||||||
|
if (!variable) {
|
||||||
|
dispatch(createAdHocVariable(options));
|
||||||
|
variable = getVariableByOptions(options, getState());
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = variable.filters.findIndex(f => f.key === options.key && f.value === options.value);
|
||||||
|
|
||||||
|
if (index === -1) {
|
||||||
|
const { value, key, operator } = options;
|
||||||
|
const filter = { value, key, operator, condition: '' };
|
||||||
|
return await dispatch(addFilter(variable.uuid!, filter));
|
||||||
|
}
|
||||||
|
|
||||||
|
const filter = { ...variable.filters[index], operator: options.operator };
|
||||||
|
return await dispatch(changeFilter(variable.uuid!, { index, filter }));
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const changeFilter = (uuid: string, update: AdHocVariabelFilterUpdate): ThunkResult<void> => {
|
||||||
|
return async (dispatch, getState) => {
|
||||||
|
const variable = getVariable(uuid, getState());
|
||||||
|
dispatch(filterUpdated(toVariablePayload(variable, update)));
|
||||||
|
await dispatch(variableUpdated(toVariableIdentifier(variable), true));
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const removeFilter = (uuid: string, index: number): ThunkResult<void> => {
|
||||||
|
return async (dispatch, getState) => {
|
||||||
|
const variable = getVariable(uuid, getState());
|
||||||
|
dispatch(filterRemoved(toVariablePayload(variable, index)));
|
||||||
|
await dispatch(variableUpdated(toVariableIdentifier(variable), true));
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const addFilter = (uuid: string, filter: AdHocVariableFilter): ThunkResult<void> => {
|
||||||
|
return async (dispatch, getState) => {
|
||||||
|
const variable = getVariable(uuid, getState());
|
||||||
|
dispatch(filterAdded(toVariablePayload(variable, filter)));
|
||||||
|
await dispatch(variableUpdated(toVariableIdentifier(variable), true));
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setFiltersFromUrl = (uuid: string, filters: AdHocVariableFilter[]): ThunkResult<void> => {
|
||||||
|
return async (dispatch, getState) => {
|
||||||
|
const variable = getVariable(uuid, getState());
|
||||||
|
dispatch(filtersRestored(toVariablePayload(variable, filters)));
|
||||||
|
await dispatch(variableUpdated(toVariableIdentifier(variable), true));
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const changeVariableDatasource = (datasource: string): ThunkResult<void> => {
|
||||||
|
return async (dispatch, getState) => {
|
||||||
|
const { editor } = getState().templating;
|
||||||
|
const variable = getVariable(editor.id, getState());
|
||||||
|
|
||||||
|
const loadingText = 'Adhoc filters are applied automatically to all queries that target this datasource';
|
||||||
|
|
||||||
|
dispatch(
|
||||||
|
changeVariableEditorExtended({
|
||||||
|
propName: 'infoText',
|
||||||
|
propValue: loadingText,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
dispatch(changeVariableProp(toVariablePayload(variable, { propName: 'datasource', propValue: datasource })));
|
||||||
|
|
||||||
|
const ds = await getDatasourceSrv().get(datasource);
|
||||||
|
|
||||||
|
if (!ds || !ds.getTagKeys) {
|
||||||
|
dispatch(
|
||||||
|
changeVariableEditorExtended({
|
||||||
|
propName: 'infoText',
|
||||||
|
propValue: 'This datasource does not support adhoc filters yet.',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const initAdHocVariableEditor = (): ThunkResult<void> => dispatch => {
|
||||||
|
const dataSources = getDatasourceSrv().getMetricSources();
|
||||||
|
const selectable = dataSources.reduce(
|
||||||
|
(all: Array<{ text: string; value: string }>, ds) => {
|
||||||
|
if (ds.meta.mixed || ds.value === null) {
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
|
||||||
|
all.push({
|
||||||
|
text: ds.name,
|
||||||
|
value: ds.value,
|
||||||
|
});
|
||||||
|
|
||||||
|
return all;
|
||||||
|
},
|
||||||
|
[{ text: '', value: '' }]
|
||||||
|
);
|
||||||
|
|
||||||
|
dispatch(
|
||||||
|
changeVariableEditorExtended({
|
||||||
|
propName: 'dataSources',
|
||||||
|
propValue: selectable,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const createAdHocVariable = (options: AdHocTableOptions): ThunkResult<void> => {
|
||||||
|
return (dispatch, getState) => {
|
||||||
|
const model = {
|
||||||
|
...cloneDeep(initialAdHocVariableModelState),
|
||||||
|
datasource: options.datasource,
|
||||||
|
name: filterTableName,
|
||||||
|
uuid: v4(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const global = false;
|
||||||
|
const index = getNewVariabelIndex(getState());
|
||||||
|
const identifier: VariableIdentifier = { type: 'adhoc', uuid: model.uuid };
|
||||||
|
|
||||||
|
dispatch(
|
||||||
|
addVariable(
|
||||||
|
toVariablePayload<AddVariable>(identifier, { global, model, index })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getVariableByOptions = (options: AdHocTableOptions, state: StoreState): AdHocVariableModel => {
|
||||||
|
return Object.values(state.templating.variables).find(
|
||||||
|
v => isAdHoc(v) && v.datasource === options.datasource
|
||||||
|
) as AdHocVariableModel;
|
||||||
|
};
|
37
public/app/features/variables/adhoc/adapter.ts
Normal file
37
public/app/features/variables/adhoc/adapter.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import cloneDeep from 'lodash/cloneDeep';
|
||||||
|
import { AdHocVariableModel } from '../../templating/variable';
|
||||||
|
import { dispatch } from '../../../store/store';
|
||||||
|
import { VariableAdapter } from '../adapters';
|
||||||
|
import { AdHocPicker } from './picker/AdHocPicker';
|
||||||
|
import { adHocVariableReducer, initialAdHocVariableModelState } from './reducer';
|
||||||
|
import { AdHocVariableEditor } from './AdHocVariableEditor';
|
||||||
|
import { setFiltersFromUrl } from './actions';
|
||||||
|
import * as urlParser from './urlParser';
|
||||||
|
|
||||||
|
const noop = async () => {};
|
||||||
|
|
||||||
|
export const createAdHocVariableAdapter = (): VariableAdapter<AdHocVariableModel> => {
|
||||||
|
return {
|
||||||
|
description: 'Add key/value filters on the fly',
|
||||||
|
label: 'Ad hoc filters',
|
||||||
|
initialState: initialAdHocVariableModelState,
|
||||||
|
reducer: adHocVariableReducer,
|
||||||
|
picker: AdHocPicker,
|
||||||
|
editor: AdHocVariableEditor,
|
||||||
|
dependsOn: () => false,
|
||||||
|
setValue: noop,
|
||||||
|
setValueFromUrl: async (variable, urlValue) => {
|
||||||
|
const filters = urlParser.toFilters(urlValue);
|
||||||
|
await dispatch(setFiltersFromUrl(variable.uuid!, filters));
|
||||||
|
},
|
||||||
|
updateOptions: noop,
|
||||||
|
getSaveModel: variable => {
|
||||||
|
const { index, uuid, initLock, global, ...rest } = cloneDeep(variable);
|
||||||
|
return rest;
|
||||||
|
},
|
||||||
|
getValueForUrl: variable => {
|
||||||
|
const filters = variable?.filters ?? [];
|
||||||
|
return urlParser.toUrl(filters);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
@ -0,0 +1,77 @@
|
|||||||
|
import React, { FC, useState, ReactElement } from 'react';
|
||||||
|
import { SegmentAsync } from '@grafana/ui';
|
||||||
|
import { OperatorSegment } from './OperatorSegment';
|
||||||
|
import { AdHocVariableFilter } from 'app/features/templating/variable';
|
||||||
|
import { SelectableValue } from '@grafana/data';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onLoadKeys: () => Promise<Array<SelectableValue<string>>>;
|
||||||
|
onLoadValues: (key: string) => Promise<Array<SelectableValue<string>>>;
|
||||||
|
onCompleted: (filter: AdHocVariableFilter) => void;
|
||||||
|
appendBefore?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AdHocFilterBuilder: FC<Props> = ({ appendBefore, onCompleted, onLoadKeys, onLoadValues }) => {
|
||||||
|
const [key, setKey] = useState<string | null>(null);
|
||||||
|
const [operator, setOperator] = useState<string>('=');
|
||||||
|
|
||||||
|
if (key === null) {
|
||||||
|
return (
|
||||||
|
<div className="gf-form">
|
||||||
|
<SegmentAsync
|
||||||
|
className="query-segment-key"
|
||||||
|
Component={filterAddButton(key)}
|
||||||
|
value={key}
|
||||||
|
onChange={({ value }) => setKey(value ?? '')}
|
||||||
|
loadOptions={onLoadKeys}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment key="filter-builder">
|
||||||
|
{appendBefore}
|
||||||
|
<div className="gf-form">
|
||||||
|
<SegmentAsync
|
||||||
|
className="query-segment-key"
|
||||||
|
value={key}
|
||||||
|
onChange={({ value }) => setKey(value ?? '')}
|
||||||
|
loadOptions={onLoadKeys}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="gf-form">
|
||||||
|
<OperatorSegment value={operator} onChange={({ value }) => setOperator(value ?? '')} />
|
||||||
|
</div>
|
||||||
|
<div className="gf-form">
|
||||||
|
<SegmentAsync
|
||||||
|
className="query-segment-value"
|
||||||
|
placeholder="select value"
|
||||||
|
onChange={({ value }) => {
|
||||||
|
onCompleted({
|
||||||
|
value: value ?? '',
|
||||||
|
operator: operator,
|
||||||
|
condition: '',
|
||||||
|
key: key,
|
||||||
|
});
|
||||||
|
setKey(null);
|
||||||
|
setOperator('=');
|
||||||
|
}}
|
||||||
|
loadOptions={() => onLoadValues(key)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function filterAddButton(key: string | null): ReactElement | undefined {
|
||||||
|
if (key !== null) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a className="gf-form-label query-part">
|
||||||
|
<i className="fa fa-plus" />
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
142
public/app/features/variables/adhoc/picker/AdHocPicker.tsx
Normal file
142
public/app/features/variables/adhoc/picker/AdHocPicker.tsx
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
import React, { PureComponent, ReactNode } from 'react';
|
||||||
|
import { connect, MapDispatchToProps, MapStateToProps } from 'react-redux';
|
||||||
|
import { StoreState } from 'app/types';
|
||||||
|
import { AdHocVariableModel, AdHocVariableFilter } from 'app/features/templating/variable';
|
||||||
|
import { SegmentAsync } from '@grafana/ui';
|
||||||
|
import { VariablePickerProps } from '../../pickers/types';
|
||||||
|
import { OperatorSegment } from './OperatorSegment';
|
||||||
|
import { SelectableValue, MetricFindValue } from '@grafana/data';
|
||||||
|
import { AdHocFilterBuilder } from './AdHocFilterBuilder';
|
||||||
|
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
|
||||||
|
import { ConditionSegment } from './ConditionSegment';
|
||||||
|
import { addFilter, removeFilter, changeFilter } from '../actions';
|
||||||
|
|
||||||
|
interface OwnProps extends VariablePickerProps<AdHocVariableModel> {}
|
||||||
|
|
||||||
|
interface ConnectedProps {}
|
||||||
|
|
||||||
|
interface DispatchProps {
|
||||||
|
addFilter: typeof addFilter;
|
||||||
|
removeFilter: typeof removeFilter;
|
||||||
|
changeFilter: typeof changeFilter;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = OwnProps & ConnectedProps & DispatchProps;
|
||||||
|
|
||||||
|
const REMOVE_FILTER_KEY = '-- remove filter --';
|
||||||
|
const REMOVE_VALUE = { label: REMOVE_FILTER_KEY, value: REMOVE_FILTER_KEY };
|
||||||
|
export class AdHocPickerUnconnected extends PureComponent<Props> {
|
||||||
|
onChange = (index: number, prop: string) => (key: SelectableValue<string>) => {
|
||||||
|
const { uuid, filters } = this.props.variable;
|
||||||
|
const { value } = key;
|
||||||
|
|
||||||
|
if (key.value === REMOVE_FILTER_KEY) {
|
||||||
|
return this.props.removeFilter(uuid!, index);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.changeFilter(uuid!, {
|
||||||
|
index,
|
||||||
|
filter: {
|
||||||
|
...filters[index],
|
||||||
|
[prop]: value,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
appendFilterToVariable = (filter: AdHocVariableFilter) => {
|
||||||
|
const { uuid } = this.props.variable;
|
||||||
|
this.props.addFilter(uuid!, filter);
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchFilterKeys = async () => {
|
||||||
|
const { variable } = this.props;
|
||||||
|
const ds = await getDatasourceSrv().get(variable.datasource!);
|
||||||
|
|
||||||
|
if (!ds || !ds.getTagKeys) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const metrics = await ds.getTagKeys();
|
||||||
|
return metrics.map(m => ({ label: m.text, value: m.text }));
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchFilterKeysWithRemove = async () => {
|
||||||
|
const keys = await this.fetchFilterKeys();
|
||||||
|
return [REMOVE_VALUE, ...keys];
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchFilterValues = async (key: string) => {
|
||||||
|
const { variable } = this.props;
|
||||||
|
const ds = await getDatasourceSrv().get(variable.datasource!);
|
||||||
|
|
||||||
|
if (!ds || !ds.getTagValues) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const metrics = await ds.getTagValues({ key });
|
||||||
|
return metrics.map((m: MetricFindValue) => ({ label: m.text, value: m.text }));
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { filters } = this.props.variable;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="gf-form-inline">
|
||||||
|
{this.renderFilters(filters)}
|
||||||
|
<AdHocFilterBuilder
|
||||||
|
appendBefore={filters.length > 0 ? <ConditionSegment label="AND" /> : null}
|
||||||
|
onLoadKeys={this.fetchFilterKeys}
|
||||||
|
onLoadValues={this.fetchFilterValues}
|
||||||
|
onCompleted={this.appendFilterToVariable}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderFilters(filters: AdHocVariableFilter[]) {
|
||||||
|
return filters.reduce((segments: ReactNode[], filter, index) => {
|
||||||
|
if (segments.length > 0) {
|
||||||
|
segments.push(<ConditionSegment label="AND" />);
|
||||||
|
}
|
||||||
|
segments.push(this.renderFilterSegments(filter, index));
|
||||||
|
return segments;
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderFilterSegments(filter: AdHocVariableFilter, index: number) {
|
||||||
|
return (
|
||||||
|
<React.Fragment key={`filter-${index}`}>
|
||||||
|
<div className="gf-form">
|
||||||
|
<SegmentAsync
|
||||||
|
className="query-segment-key"
|
||||||
|
value={filter.key}
|
||||||
|
onChange={this.onChange(index, 'key')}
|
||||||
|
loadOptions={this.fetchFilterKeysWithRemove}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="gf-form">
|
||||||
|
<OperatorSegment value={filter.operator} onChange={this.onChange(index, 'operator')} />
|
||||||
|
</div>
|
||||||
|
<div className="gf-form">
|
||||||
|
<SegmentAsync
|
||||||
|
className="query-segment-value"
|
||||||
|
value={filter.value}
|
||||||
|
onChange={this.onChange(index, 'value')}
|
||||||
|
loadOptions={() => this.fetchFilterValues(filter.key)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapDispatchToProps: MapDispatchToProps<DispatchProps, OwnProps> = {
|
||||||
|
addFilter,
|
||||||
|
removeFilter,
|
||||||
|
changeFilter,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapStateToProps: MapStateToProps<ConnectedProps, OwnProps, StoreState> = state => ({});
|
||||||
|
|
||||||
|
export const AdHocPicker = connect(mapStateToProps, mapDispatchToProps)(AdHocPickerUnconnected);
|
||||||
|
AdHocPicker.displayName = 'AdHocPicker';
|
@ -0,0 +1,13 @@
|
|||||||
|
import React, { FC } from 'react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ConditionSegment: FC<Props> = ({ label }) => {
|
||||||
|
return (
|
||||||
|
<div className="gf-form">
|
||||||
|
<span className="gf-form-label query-keyword">{label}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,17 @@
|
|||||||
|
import React, { FC } from 'react';
|
||||||
|
import { Segment } from '@grafana/ui';
|
||||||
|
import { SelectableValue } from '@grafana/data';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value: string;
|
||||||
|
onChange: (item: SelectableValue<string>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = ['=', '!=', '<', '>', '=~', '!~'].map<SelectableValue<string>>(value => ({
|
||||||
|
label: value,
|
||||||
|
value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const OperatorSegment: FC<Props> = ({ value, onChange }) => {
|
||||||
|
return <Segment className="query-segment-operator" value={value} options={options} onChange={onChange} />;
|
||||||
|
};
|
220
public/app/features/variables/adhoc/reducer.test.ts
Normal file
220
public/app/features/variables/adhoc/reducer.test.ts
Normal file
@ -0,0 +1,220 @@
|
|||||||
|
import { reducerTester } from '../../../../test/core/redux/reducerTester';
|
||||||
|
import cloneDeep from 'lodash/cloneDeep';
|
||||||
|
import { getVariableTestContext } from '../state/helpers';
|
||||||
|
import { toVariablePayload } from '../state/types';
|
||||||
|
import { adHocVariableReducer, filterAdded, filterRemoved, filterUpdated, filtersRestored } from './reducer';
|
||||||
|
import { VariablesState } from '../state/variablesReducer';
|
||||||
|
import { AdHocVariableModel, AdHocVariableFilter } from '../../templating/variable';
|
||||||
|
import { createAdHocVariableAdapter } from './adapter';
|
||||||
|
|
||||||
|
describe('adHocVariableReducer', () => {
|
||||||
|
const adapter = createAdHocVariableAdapter();
|
||||||
|
|
||||||
|
describe('when filterAdded is dispatched', () => {
|
||||||
|
it('then state should be correct', () => {
|
||||||
|
const uuid = '0';
|
||||||
|
const { initialState } = getVariableTestContext(adapter, { uuid });
|
||||||
|
const filter = createFilter('a');
|
||||||
|
const payload = toVariablePayload({ uuid, type: 'adhoc' }, filter);
|
||||||
|
|
||||||
|
reducerTester<VariablesState>()
|
||||||
|
.givenReducer(adHocVariableReducer, cloneDeep(initialState))
|
||||||
|
.whenActionIsDispatched(filterAdded(payload))
|
||||||
|
.thenStateShouldEqual({
|
||||||
|
[uuid]: {
|
||||||
|
...initialState[uuid],
|
||||||
|
filters: [{ value: 'a', operator: '=', condition: '', key: 'a' }],
|
||||||
|
} as AdHocVariableModel,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when filterAdded is dispatched and filter already exists', () => {
|
||||||
|
it('then state should be correct', () => {
|
||||||
|
const uuid = '0';
|
||||||
|
const filterA = createFilter('a');
|
||||||
|
const filterB = createFilter('b');
|
||||||
|
const { initialState } = getVariableTestContext(adapter, { uuid, filters: [filterA] });
|
||||||
|
const payload = toVariablePayload({ uuid, type: 'adhoc' }, filterB);
|
||||||
|
|
||||||
|
reducerTester<VariablesState>()
|
||||||
|
.givenReducer(adHocVariableReducer, cloneDeep(initialState))
|
||||||
|
.whenActionIsDispatched(filterAdded(payload))
|
||||||
|
.thenStateShouldEqual({
|
||||||
|
[uuid]: {
|
||||||
|
...initialState[uuid],
|
||||||
|
filters: [
|
||||||
|
{ value: 'a', operator: '=', condition: '', key: 'a' },
|
||||||
|
{ value: 'b', operator: '=', condition: '', key: 'b' },
|
||||||
|
],
|
||||||
|
} as AdHocVariableModel,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when filterRemoved is dispatched to remove second filter', () => {
|
||||||
|
it('then state should be correct', () => {
|
||||||
|
const uuid = '0';
|
||||||
|
const filterA = createFilter('a');
|
||||||
|
const filterB = createFilter('b');
|
||||||
|
const index = 1;
|
||||||
|
const { initialState } = getVariableTestContext(adapter, { uuid, filters: [filterA, filterB] });
|
||||||
|
const payload = toVariablePayload({ uuid, type: 'adhoc' }, index);
|
||||||
|
|
||||||
|
reducerTester<VariablesState>()
|
||||||
|
.givenReducer(adHocVariableReducer, cloneDeep(initialState))
|
||||||
|
.whenActionIsDispatched(filterRemoved(payload))
|
||||||
|
.thenStateShouldEqual({
|
||||||
|
[uuid]: {
|
||||||
|
...initialState[uuid],
|
||||||
|
filters: [{ value: 'a', operator: '=', condition: '', key: 'a' }],
|
||||||
|
} as AdHocVariableModel,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when filterRemoved is dispatched to remove first filter', () => {
|
||||||
|
it('then state should be correct', () => {
|
||||||
|
const uuid = '0';
|
||||||
|
const filterA = createFilter('a');
|
||||||
|
const filterB = createFilter('b');
|
||||||
|
const index = 0;
|
||||||
|
const { initialState } = getVariableTestContext(adapter, { uuid, filters: [filterA, filterB] });
|
||||||
|
const payload = toVariablePayload({ uuid, type: 'adhoc' }, index);
|
||||||
|
|
||||||
|
reducerTester<VariablesState>()
|
||||||
|
.givenReducer(adHocVariableReducer, cloneDeep(initialState))
|
||||||
|
.whenActionIsDispatched(filterRemoved(payload))
|
||||||
|
.thenStateShouldEqual({
|
||||||
|
[uuid]: {
|
||||||
|
...initialState[uuid],
|
||||||
|
filters: [{ value: 'b', operator: '=', condition: '', key: 'b' }],
|
||||||
|
} as AdHocVariableModel,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when filterRemoved is dispatched to all filters', () => {
|
||||||
|
it('then state should be correct', () => {
|
||||||
|
const uuid = '0';
|
||||||
|
const filterA = createFilter('a');
|
||||||
|
const index = 0;
|
||||||
|
const { initialState } = getVariableTestContext(adapter, { uuid, filters: [filterA] });
|
||||||
|
const payload = toVariablePayload({ uuid, type: 'adhoc' }, index);
|
||||||
|
|
||||||
|
reducerTester<VariablesState>()
|
||||||
|
.givenReducer(adHocVariableReducer, cloneDeep(initialState))
|
||||||
|
.whenActionIsDispatched(filterRemoved(payload))
|
||||||
|
.thenStateShouldEqual({
|
||||||
|
[uuid]: {
|
||||||
|
...initialState[uuid],
|
||||||
|
filters: [] as AdHocVariableFilter[],
|
||||||
|
} as AdHocVariableModel,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when filterUpdated is dispatched', () => {
|
||||||
|
it('then state should be correct', () => {
|
||||||
|
const uuid = '0';
|
||||||
|
const original = createFilter('a');
|
||||||
|
const other = createFilter('b');
|
||||||
|
const filter = createFilter('aa');
|
||||||
|
const index = 1;
|
||||||
|
const { initialState } = getVariableTestContext(adapter, { uuid, filters: [other, original] });
|
||||||
|
const payload = toVariablePayload({ uuid, type: 'adhoc' }, { index, filter });
|
||||||
|
|
||||||
|
reducerTester<VariablesState>()
|
||||||
|
.givenReducer(adHocVariableReducer, cloneDeep(initialState))
|
||||||
|
.whenActionIsDispatched(filterUpdated(payload))
|
||||||
|
.thenStateShouldEqual({
|
||||||
|
[uuid]: {
|
||||||
|
...initialState[uuid],
|
||||||
|
filters: [
|
||||||
|
{ value: 'b', operator: '=', condition: '', key: 'b' },
|
||||||
|
{ value: 'aa', operator: '=', condition: '', key: 'aa' },
|
||||||
|
],
|
||||||
|
} as AdHocVariableModel,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when filterUpdated is dispatched to update operator', () => {
|
||||||
|
it('then state should be correct', () => {
|
||||||
|
const uuid = '0';
|
||||||
|
const original = createFilter('a');
|
||||||
|
const other = createFilter('b');
|
||||||
|
const filter = createFilter('aa', '>');
|
||||||
|
const index = 1;
|
||||||
|
const { initialState } = getVariableTestContext(adapter, { uuid, filters: [other, original] });
|
||||||
|
const payload = toVariablePayload({ uuid, type: 'adhoc' }, { index, filter });
|
||||||
|
|
||||||
|
reducerTester<VariablesState>()
|
||||||
|
.givenReducer(adHocVariableReducer, cloneDeep(initialState))
|
||||||
|
.whenActionIsDispatched(filterUpdated(payload))
|
||||||
|
.thenStateShouldEqual({
|
||||||
|
[uuid]: {
|
||||||
|
...initialState[uuid],
|
||||||
|
filters: [
|
||||||
|
{ value: 'b', operator: '=', condition: '', key: 'b' },
|
||||||
|
{ value: 'aa', operator: '>', condition: '', key: 'aa' },
|
||||||
|
],
|
||||||
|
} as AdHocVariableModel,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when filtersRestored is dispatched', () => {
|
||||||
|
it('then state should be correct', () => {
|
||||||
|
const uuid = '0';
|
||||||
|
const original = [createFilter('a'), createFilter('b')];
|
||||||
|
const restored = [createFilter('aa'), createFilter('bb')];
|
||||||
|
const { initialState } = getVariableTestContext(adapter, { uuid, filters: original });
|
||||||
|
const payload = toVariablePayload({ uuid, type: 'adhoc' }, restored);
|
||||||
|
|
||||||
|
reducerTester<VariablesState>()
|
||||||
|
.givenReducer(adHocVariableReducer, cloneDeep(initialState))
|
||||||
|
.whenActionIsDispatched(filtersRestored(payload))
|
||||||
|
.thenStateShouldEqual({
|
||||||
|
[uuid]: {
|
||||||
|
...initialState[uuid],
|
||||||
|
filters: [
|
||||||
|
{ value: 'aa', operator: '=', condition: '', key: 'aa' },
|
||||||
|
{ value: 'bb', operator: '=', condition: '', key: 'bb' },
|
||||||
|
],
|
||||||
|
} as AdHocVariableModel,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when filtersRestored is dispatched on variabel with no filters', () => {
|
||||||
|
it('then state should be correct', () => {
|
||||||
|
const uuid = '0';
|
||||||
|
const restored = [createFilter('aa'), createFilter('bb')];
|
||||||
|
const { initialState } = getVariableTestContext(adapter, { uuid });
|
||||||
|
const payload = toVariablePayload({ uuid, type: 'adhoc' }, restored);
|
||||||
|
|
||||||
|
reducerTester<VariablesState>()
|
||||||
|
.givenReducer(adHocVariableReducer, cloneDeep(initialState))
|
||||||
|
.whenActionIsDispatched(filtersRestored(payload))
|
||||||
|
.thenStateShouldEqual({
|
||||||
|
[uuid]: {
|
||||||
|
...initialState[uuid],
|
||||||
|
filters: [
|
||||||
|
{ value: 'aa', operator: '=', condition: '', key: 'aa' },
|
||||||
|
{ value: 'bb', operator: '=', condition: '', key: 'bb' },
|
||||||
|
],
|
||||||
|
} as AdHocVariableModel,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function createFilter(value: string, operator = '='): AdHocVariableFilter {
|
||||||
|
return {
|
||||||
|
value,
|
||||||
|
operator,
|
||||||
|
condition: '',
|
||||||
|
key: value,
|
||||||
|
};
|
||||||
|
}
|
57
public/app/features/variables/adhoc/reducer.ts
Normal file
57
public/app/features/variables/adhoc/reducer.ts
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import { AdHocVariableModel, VariableHide, AdHocVariableFilter } from 'app/features/templating/variable';
|
||||||
|
import { EMPTY_UUID, getInstanceState, VariablePayload } from '../state/types';
|
||||||
|
import { PayloadAction, createSlice } from '@reduxjs/toolkit';
|
||||||
|
import { VariablesState, initialVariablesState } from '../state/variablesReducer';
|
||||||
|
|
||||||
|
export interface AdHocVariabelFilterUpdate {
|
||||||
|
index: number;
|
||||||
|
filter: AdHocVariableFilter;
|
||||||
|
}
|
||||||
|
export interface AdHocVariableEditorState {
|
||||||
|
infoText: string;
|
||||||
|
dataSources: Array<{ text: string; value: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const initialAdHocVariableModelState: AdHocVariableModel = {
|
||||||
|
uuid: EMPTY_UUID,
|
||||||
|
global: false,
|
||||||
|
type: 'adhoc',
|
||||||
|
name: '',
|
||||||
|
hide: VariableHide.dontHide,
|
||||||
|
label: '',
|
||||||
|
skipUrlSync: false,
|
||||||
|
index: -1,
|
||||||
|
initLock: null,
|
||||||
|
datasource: null,
|
||||||
|
filters: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const adHocVariableSlice = createSlice({
|
||||||
|
name: 'templating/adhoc',
|
||||||
|
initialState: initialVariablesState,
|
||||||
|
reducers: {
|
||||||
|
filterAdded: (state: VariablesState, action: PayloadAction<VariablePayload<AdHocVariableFilter>>) => {
|
||||||
|
const instanceState = getInstanceState<AdHocVariableModel>(state, action.payload.uuid);
|
||||||
|
instanceState.filters.push(action.payload.data);
|
||||||
|
},
|
||||||
|
filterRemoved: (state: VariablesState, action: PayloadAction<VariablePayload<number>>) => {
|
||||||
|
const instanceState = getInstanceState<AdHocVariableModel>(state, action.payload.uuid);
|
||||||
|
const index = action.payload.data;
|
||||||
|
|
||||||
|
instanceState.filters.splice(index, 1);
|
||||||
|
},
|
||||||
|
filterUpdated: (state: VariablesState, action: PayloadAction<VariablePayload<AdHocVariabelFilterUpdate>>) => {
|
||||||
|
const instanceState = getInstanceState<AdHocVariableModel>(state, action.payload.uuid);
|
||||||
|
const { filter, index } = action.payload.data;
|
||||||
|
|
||||||
|
instanceState.filters[index] = filter;
|
||||||
|
},
|
||||||
|
filtersRestored: (state: VariablesState, action: PayloadAction<VariablePayload<AdHocVariableFilter[]>>) => {
|
||||||
|
const instanceState = getInstanceState<AdHocVariableModel>(state, action.payload.uuid);
|
||||||
|
instanceState.filters = action.payload.data;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const { filterAdded, filterRemoved, filterUpdated, filtersRestored } = adHocVariableSlice.actions;
|
||||||
|
export const adHocVariableReducer = adHocVariableSlice.reducer;
|
101
public/app/features/variables/adhoc/urlParser.test.ts
Normal file
101
public/app/features/variables/adhoc/urlParser.test.ts
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
import { toUrl, toFilters } from './urlParser';
|
||||||
|
import { AdHocVariableFilter } from 'app/features/templating/variable';
|
||||||
|
import { UrlQueryValue } from '@grafana/runtime';
|
||||||
|
|
||||||
|
describe('urlParser', () => {
|
||||||
|
describe('parsing toUrl with no filters', () => {
|
||||||
|
it('then url params should be correct', () => {
|
||||||
|
const filters: AdHocVariableFilter[] = [];
|
||||||
|
const expected: string[] = [];
|
||||||
|
|
||||||
|
expect(toUrl(filters)).toEqual(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parsing toUrl with filters', () => {
|
||||||
|
it('then url params should be correct', () => {
|
||||||
|
const a = createFilter('a');
|
||||||
|
const b = createFilter('b', '>');
|
||||||
|
|
||||||
|
const filters: AdHocVariableFilter[] = [a, b];
|
||||||
|
|
||||||
|
const expectedA = `${a.key}|${a.operator}|${a.value}`;
|
||||||
|
const expectedB = `${b.key}|${b.operator}|${b.value}`;
|
||||||
|
const expected: string[] = [expectedA, expectedB];
|
||||||
|
|
||||||
|
expect(toUrl(filters)).toEqual(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parsing toUrl with filters containing special chars', () => {
|
||||||
|
it('then url params should be correct', () => {
|
||||||
|
const a = createFilter('a|');
|
||||||
|
const b = createFilter('b', '>');
|
||||||
|
|
||||||
|
const filters: AdHocVariableFilter[] = [a, b];
|
||||||
|
|
||||||
|
const expectedA = `a__gfp__-key|${a.operator}|a__gfp__-value`;
|
||||||
|
const expectedB = `${b.key}|${b.operator}|${b.value}`;
|
||||||
|
const expected: string[] = [expectedA, expectedB];
|
||||||
|
|
||||||
|
expect(toUrl(filters)).toEqual(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parsing toFilters with url containing no filters as string', () => {
|
||||||
|
it('then url params should be correct', () => {
|
||||||
|
const url: UrlQueryValue = '';
|
||||||
|
const expected: AdHocVariableFilter[] = [];
|
||||||
|
expect(toFilters(url)).toEqual(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parsing toFilters with url containing no filters as []', () => {
|
||||||
|
it('then url params should be correct', () => {
|
||||||
|
const url: UrlQueryValue = [];
|
||||||
|
const expected: AdHocVariableFilter[] = [];
|
||||||
|
expect(toFilters(url)).toEqual(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parsing toFilters with url containing one filter as string', () => {
|
||||||
|
it('then url params should be correct', () => {
|
||||||
|
const url: UrlQueryValue = 'a-key|=|a-value';
|
||||||
|
const a = createFilter('a', '=');
|
||||||
|
const expected: AdHocVariableFilter[] = [a];
|
||||||
|
|
||||||
|
expect(toFilters(url)).toEqual(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parsing toFilters with url containing filters', () => {
|
||||||
|
it('then url params should be correct', () => {
|
||||||
|
const url: UrlQueryValue = ['a-key|=|a-value', 'b-key|>|b-value'];
|
||||||
|
const a = createFilter('a', '=');
|
||||||
|
const b = createFilter('b', '>');
|
||||||
|
const expected: AdHocVariableFilter[] = [a, b];
|
||||||
|
|
||||||
|
expect(toFilters(url)).toEqual(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parsing toFilters with url containing special chars', () => {
|
||||||
|
it('then url params should be correct', () => {
|
||||||
|
const url: UrlQueryValue = ['a__gfp__-key|=|a__gfp__-value', 'b-key|>|b-value'];
|
||||||
|
const a = createFilter('a|', '=');
|
||||||
|
const b = createFilter('b', '>');
|
||||||
|
const expected: AdHocVariableFilter[] = [a, b];
|
||||||
|
|
||||||
|
expect(toFilters(url)).toEqual(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function createFilter(value: string, operator = '='): AdHocVariableFilter {
|
||||||
|
return {
|
||||||
|
value: `${value}-value`,
|
||||||
|
key: `${value}-key`,
|
||||||
|
operator: operator,
|
||||||
|
condition: '',
|
||||||
|
};
|
||||||
|
}
|
52
public/app/features/variables/adhoc/urlParser.ts
Normal file
52
public/app/features/variables/adhoc/urlParser.ts
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import { AdHocVariableFilter } from 'app/features/templating/variable';
|
||||||
|
import { UrlQueryValue } from '@grafana/runtime';
|
||||||
|
import { isString, isArray } from 'lodash';
|
||||||
|
|
||||||
|
export const toUrl = (filters: AdHocVariableFilter[]): string[] => {
|
||||||
|
return filters.map(filter =>
|
||||||
|
toArray(filter)
|
||||||
|
.map(escapeDelimiter)
|
||||||
|
.join('|')
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toFilters = (value: UrlQueryValue): AdHocVariableFilter[] => {
|
||||||
|
if (isArray(value)) {
|
||||||
|
const values = value as any[];
|
||||||
|
return values.map(toFilter).filter(isFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filter = toFilter(value);
|
||||||
|
return filter === null ? [] : [filter];
|
||||||
|
};
|
||||||
|
|
||||||
|
function escapeDelimiter(value: string) {
|
||||||
|
return value.replace(/\|/g, '__gfp__');
|
||||||
|
}
|
||||||
|
|
||||||
|
function unescapeDelimiter(value: string) {
|
||||||
|
return value.replace(/__gfp__/g, '|');
|
||||||
|
}
|
||||||
|
|
||||||
|
function toArray(filter: AdHocVariableFilter): string[] {
|
||||||
|
return [filter.key, filter.operator, filter.value];
|
||||||
|
}
|
||||||
|
|
||||||
|
function toFilter(value: string | number | boolean | undefined | null): AdHocVariableFilter | null {
|
||||||
|
if (!isString(value) || value.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = value.split('|').map(unescapeDelimiter);
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: parts[0],
|
||||||
|
operator: parts[1],
|
||||||
|
value: parts[2],
|
||||||
|
condition: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFilter(filter: AdHocVariableFilter | null): filter is AdHocVariableFilter {
|
||||||
|
return filter !== null && isString(filter.value);
|
||||||
|
}
|
@ -1,6 +1,6 @@
|
|||||||
import { reduxTester } from '../../../../test/core/redux/reduxTester';
|
import { reduxTester } from '../../../../test/core/redux/reduxTester';
|
||||||
import { TemplatingState } from '../state/reducers';
|
import { TemplatingState } from '../state/reducers';
|
||||||
import { getTemplatingRootReducer, variableMockBuilder } from '../state/helpers';
|
import { getTemplatingRootReducer } from '../state/helpers';
|
||||||
import { initDashboardTemplating } from '../state/actions';
|
import { initDashboardTemplating } from '../state/actions';
|
||||||
import { toVariableIdentifier, toVariablePayload } from '../state/types';
|
import { toVariableIdentifier, toVariablePayload } from '../state/types';
|
||||||
import { variableAdapters } from '../adapters';
|
import { variableAdapters } from '../adapters';
|
||||||
@ -15,6 +15,7 @@ import { getMockPlugin } from '../../plugins/__mocks__/pluginMocks';
|
|||||||
import { createDataSourceOptions } from './reducer';
|
import { createDataSourceOptions } from './reducer';
|
||||||
import { setCurrentVariableValue } from '../state/sharedReducer';
|
import { setCurrentVariableValue } from '../state/sharedReducer';
|
||||||
import { changeVariableEditorExtended } from '../editor/reducer';
|
import { changeVariableEditorExtended } from '../editor/reducer';
|
||||||
|
import * as variableBuilder from '../shared/testing/builders';
|
||||||
|
|
||||||
describe('data source actions', () => {
|
describe('data source actions', () => {
|
||||||
variableAdapters.set('datasource', createDataSourceVariableAdapter());
|
variableAdapters.set('datasource', createDataSourceVariableAdapter());
|
||||||
@ -40,10 +41,12 @@ describe('data source actions', () => {
|
|||||||
const getMetricSourcesMock = jest.fn().mockResolvedValue(sources);
|
const getMetricSourcesMock = jest.fn().mockResolvedValue(sources);
|
||||||
const getDatasourceSrvMock = jest.fn().mockReturnValue({ getMetricSources: getMetricSourcesMock });
|
const getDatasourceSrvMock = jest.fn().mockReturnValue({ getMetricSources: getMetricSourcesMock });
|
||||||
const dependencies: DataSourceVariableActionDependencies = { getDatasourceSrv: getDatasourceSrvMock };
|
const dependencies: DataSourceVariableActionDependencies = { getDatasourceSrv: getDatasourceSrvMock };
|
||||||
const datasource = variableMockBuilder('datasource')
|
const datasource = variableBuilder
|
||||||
.withUuid('0')
|
.datasource()
|
||||||
|
.withUUID('0')
|
||||||
.withQuery('mock-data-id')
|
.withQuery('mock-data-id')
|
||||||
.create();
|
.build();
|
||||||
|
|
||||||
const tester = await reduxTester<{ templating: TemplatingState }>()
|
const tester = await reduxTester<{ templating: TemplatingState }>()
|
||||||
.givenRootReducer(getTemplatingRootReducer())
|
.givenRootReducer(getTemplatingRootReducer())
|
||||||
.whenActionIsDispatched(initDashboardTemplating([datasource]))
|
.whenActionIsDispatched(initDashboardTemplating([datasource]))
|
||||||
@ -90,11 +93,12 @@ describe('data source actions', () => {
|
|||||||
const getMetricSourcesMock = jest.fn().mockResolvedValue(sources);
|
const getMetricSourcesMock = jest.fn().mockResolvedValue(sources);
|
||||||
const getDatasourceSrvMock = jest.fn().mockReturnValue({ getMetricSources: getMetricSourcesMock });
|
const getDatasourceSrvMock = jest.fn().mockReturnValue({ getMetricSources: getMetricSourcesMock });
|
||||||
const dependencies: DataSourceVariableActionDependencies = { getDatasourceSrv: getDatasourceSrvMock };
|
const dependencies: DataSourceVariableActionDependencies = { getDatasourceSrv: getDatasourceSrvMock };
|
||||||
const datasource = variableMockBuilder('datasource')
|
const datasource = variableBuilder
|
||||||
.withUuid('0')
|
.datasource()
|
||||||
|
.withUUID('0')
|
||||||
.withQuery('mock-data-id')
|
.withQuery('mock-data-id')
|
||||||
.withRegEx('/.*(second-name).*/')
|
.withRegEx('/.*(second-name).*/')
|
||||||
.create();
|
.build();
|
||||||
const tester = await reduxTester<{ templating: TemplatingState }>()
|
const tester = await reduxTester<{ templating: TemplatingState }>()
|
||||||
.givenRootReducer(getTemplatingRootReducer())
|
.givenRootReducer(getTemplatingRootReducer())
|
||||||
.whenActionIsDispatched(initDashboardTemplating([datasource]))
|
.whenActionIsDispatched(initDashboardTemplating([datasource]))
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { ThunkResult } from '../../../types';
|
import { ThunkResult } from '../../../types';
|
||||||
import { getVariable, getVariables } from '../state/selectors';
|
import { getVariable, getVariables, getNewVariabelIndex } from '../state/selectors';
|
||||||
import {
|
import {
|
||||||
changeVariableNameFailed,
|
changeVariableNameFailed,
|
||||||
changeVariableNameSucceeded,
|
changeVariableNameSucceeded,
|
||||||
@ -82,7 +82,7 @@ export const switchToNewMode = (): ThunkResult<void> => (dispatch, getState) =>
|
|||||||
const uuid = EMPTY_UUID;
|
const uuid = EMPTY_UUID;
|
||||||
const global = false;
|
const global = false;
|
||||||
const model = cloneDeep(variableAdapters.get(type).initialState);
|
const model = cloneDeep(variableAdapters.get(type).initialState);
|
||||||
const index = Object.values(getState().templating.variables).length;
|
const index = getNewVariabelIndex(getState());
|
||||||
const identifier = { type, uuid };
|
const identifier = { type, uuid };
|
||||||
dispatch(
|
dispatch(
|
||||||
addVariable(
|
addVariable(
|
||||||
|
@ -1,5 +1,9 @@
|
|||||||
import { QueryVariableModel, VariableModel } from '../templating/variable';
|
import { QueryVariableModel, VariableModel, AdHocVariableModel } from '../templating/variable';
|
||||||
|
|
||||||
export const isQuery = (model: VariableModel): model is QueryVariableModel => {
|
export const isQuery = (model: VariableModel): model is QueryVariableModel => {
|
||||||
return model.type === 'query';
|
return model.type === 'query';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const isAdHoc = (model: VariableModel): model is AdHocVariableModel => {
|
||||||
|
return model.type === 'adhoc';
|
||||||
|
};
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { getTemplatingRootReducer, variableMockBuilder } from '../state/helpers';
|
import { getTemplatingRootReducer } from '../state/helpers';
|
||||||
import { reduxTester } from '../../../../test/core/redux/reduxTester';
|
import { reduxTester } from '../../../../test/core/redux/reduxTester';
|
||||||
import { TemplatingState } from '../state/reducers';
|
import { TemplatingState } from '../state/reducers';
|
||||||
import { initDashboardTemplating } from '../state/actions';
|
import { initDashboardTemplating } from '../state/actions';
|
||||||
@ -17,16 +17,18 @@ import { Emitter } from 'app/core/core';
|
|||||||
import { AppEvents, dateTime } from '@grafana/data';
|
import { AppEvents, dateTime } from '@grafana/data';
|
||||||
import { getTimeSrv, setTimeSrv, TimeSrv } from '../../dashboard/services/TimeSrv';
|
import { getTimeSrv, setTimeSrv, TimeSrv } from '../../dashboard/services/TimeSrv';
|
||||||
import { TemplateSrv } from '../../templating/template_srv';
|
import { TemplateSrv } from '../../templating/template_srv';
|
||||||
|
import * as variableBuilder from '../shared/testing/builders';
|
||||||
|
|
||||||
describe('interval actions', () => {
|
describe('interval actions', () => {
|
||||||
variableAdapters.set('interval', createIntervalVariableAdapter());
|
variableAdapters.set('interval', createIntervalVariableAdapter());
|
||||||
describe('when updateIntervalVariableOptions is dispatched', () => {
|
describe('when updateIntervalVariableOptions is dispatched', () => {
|
||||||
it('then correct actions are dispatched', async () => {
|
it('then correct actions are dispatched', async () => {
|
||||||
const interval = variableMockBuilder('interval')
|
const interval = variableBuilder
|
||||||
.withUuid('0')
|
.interval()
|
||||||
|
.withUUID('0')
|
||||||
.withQuery('1s,1m,1h,1d')
|
.withQuery('1s,1m,1h,1d')
|
||||||
.withAuto(false)
|
.withAuto(false)
|
||||||
.create();
|
.build();
|
||||||
|
|
||||||
const tester = await reduxTester<{ templating: TemplatingState }>()
|
const tester = await reduxTester<{ templating: TemplatingState }>()
|
||||||
.givenRootReducer(getTemplatingRootReducer())
|
.givenRootReducer(getTemplatingRootReducer())
|
||||||
@ -60,12 +62,13 @@ describe('interval actions', () => {
|
|||||||
} as unknown) as TimeSrv;
|
} as unknown) as TimeSrv;
|
||||||
const originalTimeSrv = getTimeSrv();
|
const originalTimeSrv = getTimeSrv();
|
||||||
setTimeSrv(timeSrvMock);
|
setTimeSrv(timeSrvMock);
|
||||||
const interval = variableMockBuilder('interval')
|
const interval = variableBuilder
|
||||||
.withUuid('0')
|
.interval()
|
||||||
|
.withUUID('0')
|
||||||
.withQuery('1s,1m,1h,1d')
|
.withQuery('1s,1m,1h,1d')
|
||||||
.withAuto(true)
|
.withAuto(true)
|
||||||
.withAutoMin('1') // illegal interval string
|
.withAutoMin('1') // illegal interval string
|
||||||
.create();
|
.build();
|
||||||
const appEventMock = ({
|
const appEventMock = ({
|
||||||
emit: jest.fn(),
|
emit: jest.fn(),
|
||||||
} as unknown) as Emitter;
|
} as unknown) as Emitter;
|
||||||
@ -88,10 +91,11 @@ describe('interval actions', () => {
|
|||||||
describe('when updateAutoValue is dispatched', () => {
|
describe('when updateAutoValue is dispatched', () => {
|
||||||
describe('and auto is false', () => {
|
describe('and auto is false', () => {
|
||||||
it('then no dependencies are called', async () => {
|
it('then no dependencies are called', async () => {
|
||||||
const interval = variableMockBuilder('interval')
|
const interval = variableBuilder
|
||||||
.withUuid('0')
|
.interval()
|
||||||
|
.withUUID('0')
|
||||||
.withAuto(false)
|
.withAuto(false)
|
||||||
.create();
|
.build();
|
||||||
|
|
||||||
const dependencies: UpdateAutoValueDependencies = {
|
const dependencies: UpdateAutoValueDependencies = {
|
||||||
kbn: {
|
kbn: {
|
||||||
@ -127,13 +131,14 @@ describe('interval actions', () => {
|
|||||||
|
|
||||||
describe('and auto is true', () => {
|
describe('and auto is true', () => {
|
||||||
it('then correct dependencies are called', async () => {
|
it('then correct dependencies are called', async () => {
|
||||||
const interval = variableMockBuilder('interval')
|
const interval = variableBuilder
|
||||||
.withUuid('0')
|
.interval()
|
||||||
|
.withUUID('0')
|
||||||
.withName('intervalName')
|
.withName('intervalName')
|
||||||
.withAuto(true)
|
.withAuto(true)
|
||||||
.withAutoCount(33)
|
.withAutoCount(33)
|
||||||
.withAutoMin('13s')
|
.withAutoMin('13s')
|
||||||
.create();
|
.build();
|
||||||
|
|
||||||
const timeRangeMock = jest.fn().mockReturnValue({
|
const timeRangeMock = jest.fn().mockReturnValue({
|
||||||
from: '2001-01-01',
|
from: '2001-01-01',
|
||||||
|
@ -0,0 +1,14 @@
|
|||||||
|
import { AdHocVariableModel, AdHocVariableFilter } from 'app/features/templating/variable';
|
||||||
|
import { VariableBuilder } from './variableBuilder';
|
||||||
|
|
||||||
|
export class AdHocVariableBuilder extends VariableBuilder<AdHocVariableModel> {
|
||||||
|
withDatasource(datasource: string) {
|
||||||
|
this.variable.datasource = datasource;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
withFilters(filters: AdHocVariableFilter[]) {
|
||||||
|
this.variable.filters = filters;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
20
public/app/features/variables/shared/testing/builders.ts
Normal file
20
public/app/features/variables/shared/testing/builders.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { AdHocVariableBuilder } from './adHocVariableBuilder';
|
||||||
|
import { IntervalVariableBuilder } from './intervalVariableBuilder';
|
||||||
|
import { DatasourceVariableBuilder } from './datasourceVariableBuilder';
|
||||||
|
import { OptionsVariableBuilder } from './optionsVariableBuilder';
|
||||||
|
import { initialQueryVariableModelState } from '../../query/reducer';
|
||||||
|
import { initialAdHocVariableModelState } from '../../adhoc/reducer';
|
||||||
|
import { initialDataSourceVariableModelState } from '../../datasource/reducer';
|
||||||
|
import { initialIntervalVariableModelState } from '../../interval/reducer';
|
||||||
|
import { initialTextBoxVariableModelState } from '../../textbox/reducer';
|
||||||
|
import { initialCustomVariableModelState } from '../../custom/reducer';
|
||||||
|
import { MultiVariableBuilder } from './multiVariableBuilder';
|
||||||
|
import { initialConstantVariableModelState } from '../../constant/reducer';
|
||||||
|
|
||||||
|
export const adHoc = () => new AdHocVariableBuilder(initialAdHocVariableModelState);
|
||||||
|
export const interval = () => new IntervalVariableBuilder(initialIntervalVariableModelState);
|
||||||
|
export const datasource = () => new DatasourceVariableBuilder(initialDataSourceVariableModelState);
|
||||||
|
export const query = () => new DatasourceVariableBuilder(initialQueryVariableModelState);
|
||||||
|
export const textbox = () => new OptionsVariableBuilder(initialTextBoxVariableModelState);
|
||||||
|
export const custom = () => new MultiVariableBuilder(initialCustomVariableModelState);
|
||||||
|
export const constant = () => new OptionsVariableBuilder(initialConstantVariableModelState);
|
@ -0,0 +1,14 @@
|
|||||||
|
import { MultiVariableBuilder } from './multiVariableBuilder';
|
||||||
|
import { DataSourceVariableModel, VariableRefresh } from 'app/features/templating/variable';
|
||||||
|
|
||||||
|
export class DatasourceVariableBuilder<T extends DataSourceVariableModel> extends MultiVariableBuilder<T> {
|
||||||
|
withRefresh(refresh: VariableRefresh) {
|
||||||
|
this.variable.refresh = refresh;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
withRegEx(regex: any) {
|
||||||
|
this.variable.regex = regex;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,24 @@
|
|||||||
|
import { OptionsVariableBuilder } from './optionsVariableBuilder';
|
||||||
|
import { IntervalVariableModel, VariableRefresh } from 'app/features/templating/variable';
|
||||||
|
|
||||||
|
export class IntervalVariableBuilder extends OptionsVariableBuilder<IntervalVariableModel> {
|
||||||
|
withRefresh(refresh: VariableRefresh) {
|
||||||
|
this.variable.refresh = refresh;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
withAuto(auto: boolean) {
|
||||||
|
this.variable.auto = auto;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
withAutoCount(autoCount: number) {
|
||||||
|
this.variable.auto_count = autoCount;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
withAutoMin(autoMin: string) {
|
||||||
|
this.variable.auto_min = autoMin;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,9 @@
|
|||||||
|
import { VariableWithMultiSupport } from 'app/features/templating/variable';
|
||||||
|
import { OptionsVariableBuilder } from './optionsVariableBuilder';
|
||||||
|
|
||||||
|
export class MultiVariableBuilder<T extends VariableWithMultiSupport> extends OptionsVariableBuilder<T> {
|
||||||
|
withMulti(multi = true) {
|
||||||
|
this.variable.multi = multi;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,35 @@
|
|||||||
|
import { VariableWithOptions, VariableOption } from 'app/features/templating/variable';
|
||||||
|
import { VariableBuilder } from './variableBuilder';
|
||||||
|
|
||||||
|
export class OptionsVariableBuilder<T extends VariableWithOptions> extends VariableBuilder<T> {
|
||||||
|
withOptions(...texts: string[]) {
|
||||||
|
this.variable.options = [];
|
||||||
|
for (let index = 0; index < texts.length; index++) {
|
||||||
|
this.variable.options.push({
|
||||||
|
text: texts[index],
|
||||||
|
value: texts[index],
|
||||||
|
selected: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
withoutOptions() {
|
||||||
|
this.variable.options = (undefined as unknown) as VariableOption[];
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
withCurrent(text: string | string[], value?: string | string[]) {
|
||||||
|
this.variable.current = {
|
||||||
|
text,
|
||||||
|
value: value ?? text,
|
||||||
|
selected: true,
|
||||||
|
};
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
withQuery(query: string) {
|
||||||
|
this.variable.query = query;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,25 @@
|
|||||||
|
import cloneDeep from 'lodash/cloneDeep';
|
||||||
|
import { VariableModel } from 'app/features/templating/variable';
|
||||||
|
|
||||||
|
export class VariableBuilder<T extends VariableModel> {
|
||||||
|
protected variable: T;
|
||||||
|
|
||||||
|
constructor(initialState: T) {
|
||||||
|
const { uuid, index, global, ...rest } = initialState;
|
||||||
|
this.variable = cloneDeep({ ...rest, name: rest.type }) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
withName(name: string) {
|
||||||
|
this.variable.name = name;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
withUUID(uuid: string) {
|
||||||
|
this.variable.uuid = uuid;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
build(): T {
|
||||||
|
return this.variable;
|
||||||
|
}
|
||||||
|
}
|
@ -1,7 +1,7 @@
|
|||||||
import { AnyAction } from 'redux';
|
import { AnyAction } from 'redux';
|
||||||
import { UrlQueryMap } from '@grafana/runtime';
|
import { UrlQueryMap } from '@grafana/runtime';
|
||||||
|
|
||||||
import { getTemplatingAndLocationRootReducer, getTemplatingRootReducer, variableMockBuilder } from './helpers';
|
import { getTemplatingAndLocationRootReducer, getTemplatingRootReducer } from './helpers';
|
||||||
import { variableAdapters } from '../adapters';
|
import { variableAdapters } from '../adapters';
|
||||||
import { createQueryVariableAdapter } from '../query/adapter';
|
import { createQueryVariableAdapter } from '../query/adapter';
|
||||||
import { createCustomVariableAdapter } from '../custom/adapter';
|
import { createCustomVariableAdapter } from '../custom/adapter';
|
||||||
@ -26,6 +26,7 @@ import { VariableRefresh } from '../../templating/variable';
|
|||||||
import { DashboardModel } from '../../dashboard/state';
|
import { DashboardModel } from '../../dashboard/state';
|
||||||
import { DashboardState } from '../../../types';
|
import { DashboardState } from '../../../types';
|
||||||
import { dateTime, TimeRange } from '@grafana/data';
|
import { dateTime, TimeRange } from '@grafana/data';
|
||||||
|
import * as variableBuilder from '../shared/testing/builders';
|
||||||
|
|
||||||
describe('shared actions', () => {
|
describe('shared actions', () => {
|
||||||
describe('when initDashboardTemplating is dispatched', () => {
|
describe('when initDashboardTemplating is dispatched', () => {
|
||||||
@ -34,11 +35,11 @@ describe('shared actions', () => {
|
|||||||
variableAdapters.set('custom', createCustomVariableAdapter());
|
variableAdapters.set('custom', createCustomVariableAdapter());
|
||||||
variableAdapters.set('textbox', createTextBoxVariableAdapter());
|
variableAdapters.set('textbox', createTextBoxVariableAdapter());
|
||||||
variableAdapters.set('constant', createConstantVariableAdapter());
|
variableAdapters.set('constant', createConstantVariableAdapter());
|
||||||
const query = variableMockBuilder('query').create();
|
const query = variableBuilder.query().build();
|
||||||
const constant = variableMockBuilder('constant').create();
|
const constant = variableBuilder.constant().build();
|
||||||
const datasource = variableMockBuilder('datasource').create();
|
const datasource = variableBuilder.datasource().build();
|
||||||
const custom = variableMockBuilder('custom').create();
|
const custom = variableBuilder.custom().build();
|
||||||
const textbox = variableMockBuilder('textbox').create();
|
const textbox = variableBuilder.textbox().build();
|
||||||
const list = [query, constant, datasource, custom, textbox];
|
const list = [query, constant, datasource, custom, textbox];
|
||||||
|
|
||||||
reduxTester<{ templating: TemplatingState }>()
|
reduxTester<{ templating: TemplatingState }>()
|
||||||
@ -85,11 +86,11 @@ describe('shared actions', () => {
|
|||||||
variableAdapters.set('custom', createCustomVariableAdapter());
|
variableAdapters.set('custom', createCustomVariableAdapter());
|
||||||
variableAdapters.set('textbox', createTextBoxVariableAdapter());
|
variableAdapters.set('textbox', createTextBoxVariableAdapter());
|
||||||
variableAdapters.set('constant', createConstantVariableAdapter());
|
variableAdapters.set('constant', createConstantVariableAdapter());
|
||||||
const query = variableMockBuilder('query').create();
|
const query = variableBuilder.query().build();
|
||||||
const constant = variableMockBuilder('constant').create();
|
const constant = variableBuilder.constant().build();
|
||||||
const datasource = variableMockBuilder('datasource').create();
|
const datasource = variableBuilder.datasource().build();
|
||||||
const custom = variableMockBuilder('custom').create();
|
const custom = variableBuilder.custom().build();
|
||||||
const textbox = variableMockBuilder('textbox').create();
|
const textbox = variableBuilder.textbox().build();
|
||||||
const list = [query, constant, datasource, custom, textbox];
|
const list = [query, constant, datasource, custom, textbox];
|
||||||
|
|
||||||
const tester = await reduxTester<{ templating: TemplatingState; location: { query: UrlQueryMap } }>({
|
const tester = await reduxTester<{ templating: TemplatingState; location: { query: UrlQueryMap } }>({
|
||||||
@ -145,11 +146,12 @@ describe('shared actions', () => {
|
|||||||
${undefined} | ${[undefined]}
|
${undefined} | ${[undefined]}
|
||||||
`('and urlValue is $urlValue then correct actions are dispatched', async ({ urlValue, expected }) => {
|
`('and urlValue is $urlValue then correct actions are dispatched', async ({ urlValue, expected }) => {
|
||||||
variableAdapters.set('custom', createCustomVariableAdapter());
|
variableAdapters.set('custom', createCustomVariableAdapter());
|
||||||
const custom = variableMockBuilder('custom')
|
const custom = variableBuilder
|
||||||
.withUuid('0')
|
.custom()
|
||||||
|
.withUUID('0')
|
||||||
.withOptions('A', 'B', 'C')
|
.withOptions('A', 'B', 'C')
|
||||||
.withCurrent('A')
|
.withCurrent('A')
|
||||||
.create();
|
.build();
|
||||||
|
|
||||||
const tester = await reduxTester<{ templating: TemplatingState }>()
|
const tester = await reduxTester<{ templating: TemplatingState }>()
|
||||||
.givenRootReducer(getTemplatingRootReducer())
|
.givenRootReducer(getTemplatingRootReducer())
|
||||||
@ -182,19 +184,19 @@ describe('shared actions', () => {
|
|||||||
let custom;
|
let custom;
|
||||||
|
|
||||||
if (!withOptions) {
|
if (!withOptions) {
|
||||||
custom = variableMockBuilder('custom')
|
custom = variableBuilder
|
||||||
.withUuid('0')
|
.custom()
|
||||||
|
.withUUID('0')
|
||||||
.withCurrent(withCurrent)
|
.withCurrent(withCurrent)
|
||||||
.create();
|
.withoutOptions()
|
||||||
custom.options = undefined;
|
.build();
|
||||||
}
|
} else {
|
||||||
|
custom = variableBuilder
|
||||||
if (withOptions) {
|
.custom()
|
||||||
custom = variableMockBuilder('custom')
|
.withUUID('0')
|
||||||
.withUuid('0')
|
|
||||||
.withOptions(...withOptions)
|
.withOptions(...withOptions)
|
||||||
.withCurrent(withCurrent)
|
.withCurrent(withCurrent)
|
||||||
.create();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
const tester = await reduxTester<{ templating: TemplatingState }>()
|
const tester = await reduxTester<{ templating: TemplatingState }>()
|
||||||
@ -238,21 +240,21 @@ describe('shared actions', () => {
|
|||||||
let custom;
|
let custom;
|
||||||
|
|
||||||
if (!withOptions) {
|
if (!withOptions) {
|
||||||
custom = variableMockBuilder('custom')
|
custom = variableBuilder
|
||||||
.withUuid('0')
|
.custom()
|
||||||
|
.withUUID('0')
|
||||||
.withMulti()
|
.withMulti()
|
||||||
.withCurrent(withCurrent)
|
.withCurrent(withCurrent)
|
||||||
.create();
|
.withoutOptions()
|
||||||
custom.options = undefined;
|
.build();
|
||||||
}
|
} else {
|
||||||
|
custom = variableBuilder
|
||||||
if (withOptions) {
|
.custom()
|
||||||
custom = variableMockBuilder('custom')
|
.withUUID('0')
|
||||||
.withUuid('0')
|
|
||||||
.withMulti()
|
.withMulti()
|
||||||
.withOptions(...withOptions)
|
.withOptions(...withOptions)
|
||||||
.withCurrent(withCurrent)
|
.withCurrent(withCurrent)
|
||||||
.create();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
const tester = await reduxTester<{ templating: TemplatingState }>()
|
const tester = await reduxTester<{ templating: TemplatingState }>()
|
||||||
@ -314,34 +316,37 @@ describe('shared actions', () => {
|
|||||||
variableAdapters.set('constant', createConstantVariableAdapter());
|
variableAdapters.set('constant', createConstantVariableAdapter());
|
||||||
|
|
||||||
// initial variable state
|
// initial variable state
|
||||||
const initialVariable = variableMockBuilder('interval')
|
const initialVariable = variableBuilder
|
||||||
.withUuid('0')
|
.interval()
|
||||||
|
.withUUID('0')
|
||||||
.withName('interval-0')
|
.withName('interval-0')
|
||||||
.withOptions('1m', '10m', '30m', '1h', '6h', '12h', '1d', '7d', '14d', '30d')
|
.withOptions('1m', '10m', '30m', '1h', '6h', '12h', '1d', '7d', '14d', '30d')
|
||||||
.withCurrent('1m')
|
.withCurrent('1m')
|
||||||
.withRefresh(VariableRefresh.onTimeRangeChanged)
|
.withRefresh(VariableRefresh.onTimeRangeChanged)
|
||||||
.create();
|
.build();
|
||||||
|
|
||||||
// the constant variable should be filtered out
|
// the constant variable should be filtered out
|
||||||
const constant = variableMockBuilder('constant')
|
const constant = variableBuilder
|
||||||
.withUuid('1')
|
.constant()
|
||||||
|
.withUUID('1')
|
||||||
.withName('constant-1')
|
.withName('constant-1')
|
||||||
.withOptions('a constant')
|
.withOptions('a constant')
|
||||||
.withCurrent('a constant')
|
.withCurrent('a constant')
|
||||||
.create();
|
.build();
|
||||||
const initialState = {
|
const initialState = {
|
||||||
templating: { variables: { '0': { ...initialVariable }, '1': { ...constant } } },
|
templating: { variables: { '0': { ...initialVariable }, '1': { ...constant } } },
|
||||||
dashboard,
|
dashboard,
|
||||||
};
|
};
|
||||||
|
|
||||||
// updated variable state
|
// updated variable state
|
||||||
const updatedVariable = variableMockBuilder('interval')
|
const updatedVariable = variableBuilder
|
||||||
.withUuid('0')
|
.interval()
|
||||||
|
.withUUID('0')
|
||||||
.withName('interval-0')
|
.withName('interval-0')
|
||||||
.withOptions('1m')
|
.withOptions('1m')
|
||||||
.withCurrent('1m')
|
.withCurrent('1m')
|
||||||
.withRefresh(VariableRefresh.onTimeRangeChanged)
|
.withRefresh(VariableRefresh.onTimeRangeChanged)
|
||||||
.create();
|
.build();
|
||||||
|
|
||||||
const variable = args.update ? { ...updatedVariable } : { ...initialVariable };
|
const variable = args.update ? { ...updatedVariable } : { ...initialVariable };
|
||||||
const state = { templating: { variables: { '0': variable, '1': { ...constant } } }, dashboard };
|
const state = { templating: { variables: { '0': variable, '1': { ...constant } } }, dashboard };
|
||||||
|
@ -1,13 +1,13 @@
|
|||||||
import { combineReducers } from '@reduxjs/toolkit';
|
import { combineReducers } from '@reduxjs/toolkit';
|
||||||
import cloneDeep from 'lodash/cloneDeep';
|
|
||||||
|
|
||||||
import { EMPTY_UUID } from './types';
|
import { EMPTY_UUID } from './types';
|
||||||
import { VariableHide, VariableModel, VariableRefresh, VariableType } from '../../templating/variable';
|
import { VariableHide, VariableModel } from '../../templating/variable';
|
||||||
import { variablesReducer, VariablesState } from './variablesReducer';
|
import { variablesReducer, VariablesState } from './variablesReducer';
|
||||||
import { optionsPickerReducer } from '../pickers/OptionsPicker/reducer';
|
import { optionsPickerReducer } from '../pickers/OptionsPicker/reducer';
|
||||||
import { variableEditorReducer } from '../editor/reducer';
|
import { variableEditorReducer } from '../editor/reducer';
|
||||||
import { locationReducer } from '../../../core/reducers/location';
|
import { locationReducer } from '../../../core/reducers/location';
|
||||||
import { VariableAdapter, variableAdapters } from '../adapters';
|
import { VariableAdapter } from '../adapters';
|
||||||
|
import { dashboardReducer } from 'app/features/dashboard/state/reducers';
|
||||||
|
|
||||||
export const getVariableState = (
|
export const getVariableState = (
|
||||||
noOfVariables: number,
|
noOfVariables: number,
|
||||||
@ -61,90 +61,16 @@ export const getVariableTestContext = <Model extends VariableModel>(
|
|||||||
return { initialState };
|
return { initialState };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const variableMockBuilder = (type: VariableType) => {
|
export const getRootReducer = () =>
|
||||||
const initialState = variableAdapters.contains(type)
|
combineReducers({
|
||||||
? cloneDeep(variableAdapters.get(type).initialState)
|
location: locationReducer,
|
||||||
: { name: type, type, label: '', hide: VariableHide.dontHide, skipUrlSync: false };
|
dashboard: dashboardReducer,
|
||||||
const { uuid, index, global, ...rest } = initialState;
|
templating: combineReducers({
|
||||||
const model = { ...rest, name: type };
|
optionsPicker: optionsPickerReducer,
|
||||||
|
editor: variableEditorReducer,
|
||||||
const withUuid = (uuid: string) => {
|
variables: variablesReducer,
|
||||||
model.uuid = uuid;
|
}),
|
||||||
return instance;
|
});
|
||||||
};
|
|
||||||
|
|
||||||
const withName = (name: string) => {
|
|
||||||
model.name = name;
|
|
||||||
return instance;
|
|
||||||
};
|
|
||||||
|
|
||||||
const withOptions = (...texts: string[]) => {
|
|
||||||
model.options = [];
|
|
||||||
for (let index = 0; index < texts.length; index++) {
|
|
||||||
model.options.push({ text: texts[index], value: texts[index], selected: false });
|
|
||||||
}
|
|
||||||
return instance;
|
|
||||||
};
|
|
||||||
|
|
||||||
const withCurrent = (text: string | string[], value?: string | string[]) => {
|
|
||||||
model.current = { text, value: value ?? text, selected: true };
|
|
||||||
return instance;
|
|
||||||
};
|
|
||||||
|
|
||||||
const withRefresh = (refresh: VariableRefresh) => {
|
|
||||||
model.refresh = refresh;
|
|
||||||
return instance;
|
|
||||||
};
|
|
||||||
|
|
||||||
const withQuery = (query: string) => {
|
|
||||||
model.query = query;
|
|
||||||
return instance;
|
|
||||||
};
|
|
||||||
|
|
||||||
const withMulti = () => {
|
|
||||||
model.multi = true;
|
|
||||||
return instance;
|
|
||||||
};
|
|
||||||
|
|
||||||
const withRegEx = (regex: any) => {
|
|
||||||
model.regex = regex;
|
|
||||||
return instance;
|
|
||||||
};
|
|
||||||
|
|
||||||
const withAuto = (auto: boolean) => {
|
|
||||||
model.auto = auto;
|
|
||||||
return instance;
|
|
||||||
};
|
|
||||||
|
|
||||||
const withAutoCount = (autoCount: number) => {
|
|
||||||
model.auto_count = autoCount;
|
|
||||||
return instance;
|
|
||||||
};
|
|
||||||
|
|
||||||
const withAutoMin = (autoMin: string) => {
|
|
||||||
model.auto_min = autoMin;
|
|
||||||
return instance;
|
|
||||||
};
|
|
||||||
|
|
||||||
const create = () => model;
|
|
||||||
|
|
||||||
const instance = {
|
|
||||||
withUuid,
|
|
||||||
withName,
|
|
||||||
withOptions,
|
|
||||||
withCurrent,
|
|
||||||
withRefresh,
|
|
||||||
withQuery,
|
|
||||||
withMulti,
|
|
||||||
withRegEx,
|
|
||||||
withAuto,
|
|
||||||
withAutoCount,
|
|
||||||
withAutoMin,
|
|
||||||
create,
|
|
||||||
};
|
|
||||||
|
|
||||||
return instance;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getTemplatingRootReducer = () =>
|
export const getTemplatingRootReducer = () =>
|
||||||
combineReducers({
|
combineReducers({
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { UrlQueryMap } from '@grafana/runtime';
|
import { UrlQueryMap } from '@grafana/runtime';
|
||||||
|
|
||||||
import { getTemplatingRootReducer, variableMockBuilder } from './helpers';
|
import { getTemplatingRootReducer } from './helpers';
|
||||||
import { variableAdapters } from '../adapters';
|
import { variableAdapters } from '../adapters';
|
||||||
import { createQueryVariableAdapter } from '../query/adapter';
|
import { createQueryVariableAdapter } from '../query/adapter';
|
||||||
import { createCustomVariableAdapter } from '../custom/adapter';
|
import { createCustomVariableAdapter } from '../custom/adapter';
|
||||||
@ -11,6 +11,7 @@ import { resolveInitLock, setCurrentVariableValue } from './sharedReducer';
|
|||||||
import { toVariableIdentifier, toVariablePayload } from './types';
|
import { toVariableIdentifier, toVariablePayload } from './types';
|
||||||
import { VariableRefresh } from '../../templating/variable';
|
import { VariableRefresh } from '../../templating/variable';
|
||||||
import { updateVariableOptions } from '../query/reducer';
|
import { updateVariableOptions } from '../query/reducer';
|
||||||
|
import * as variableBuilder from '../shared/testing/builders';
|
||||||
|
|
||||||
jest.mock('app/features/dashboard/services/TimeSrv', () => ({
|
jest.mock('app/features/dashboard/services/TimeSrv', () => ({
|
||||||
getTimeSrv: jest.fn().mockReturnValue({
|
getTimeSrv: jest.fn().mockReturnValue({
|
||||||
@ -67,28 +68,31 @@ describe('processVariable', () => {
|
|||||||
const getAndSetupProcessVariableContext = () => {
|
const getAndSetupProcessVariableContext = () => {
|
||||||
variableAdapters.set('custom', createCustomVariableAdapter());
|
variableAdapters.set('custom', createCustomVariableAdapter());
|
||||||
variableAdapters.set('query', createQueryVariableAdapter());
|
variableAdapters.set('query', createQueryVariableAdapter());
|
||||||
const custom = variableMockBuilder('custom')
|
const custom = variableBuilder
|
||||||
.withUuid('0')
|
.custom()
|
||||||
|
.withUUID('0')
|
||||||
.withQuery('A,B,C')
|
.withQuery('A,B,C')
|
||||||
.withOptions('A', 'B', 'C')
|
.withOptions('A', 'B', 'C')
|
||||||
.withCurrent('A')
|
.withCurrent('A')
|
||||||
.create();
|
.build();
|
||||||
|
|
||||||
const queryDependsOnCustom = variableMockBuilder('query')
|
const queryDependsOnCustom = variableBuilder
|
||||||
.withUuid('1')
|
.query()
|
||||||
|
.withUUID('1')
|
||||||
.withName('queryDependsOnCustom')
|
.withName('queryDependsOnCustom')
|
||||||
.withQuery('$custom.*')
|
.withQuery('$custom.*')
|
||||||
.withOptions('AA', 'AB', 'AC')
|
.withOptions('AA', 'AB', 'AC')
|
||||||
.withCurrent('AA')
|
.withCurrent('AA')
|
||||||
.create();
|
.build();
|
||||||
|
|
||||||
const queryNoDepends = variableMockBuilder('query')
|
const queryNoDepends = variableBuilder
|
||||||
.withUuid('2')
|
.query()
|
||||||
|
.withUUID('2')
|
||||||
.withName('queryNoDepends')
|
.withName('queryNoDepends')
|
||||||
.withQuery('*')
|
.withQuery('*')
|
||||||
.withOptions('A', 'B', 'C')
|
.withOptions('A', 'B', 'C')
|
||||||
.withCurrent('A')
|
.withCurrent('A')
|
||||||
.create();
|
.build();
|
||||||
|
|
||||||
const list = [custom, queryDependsOnCustom, queryNoDepends];
|
const list = [custom, queryDependsOnCustom, queryNoDepends];
|
||||||
|
|
||||||
|
@ -12,8 +12,8 @@ export interface TemplatingState {
|
|||||||
|
|
||||||
export default {
|
export default {
|
||||||
templating: combineReducers({
|
templating: combineReducers({
|
||||||
optionsPicker: optionsPickerReducer,
|
|
||||||
editor: variableEditorReducer,
|
editor: variableEditorReducer,
|
||||||
variables: variablesReducer,
|
variables: variablesReducer,
|
||||||
|
optionsPicker: optionsPickerReducer,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
@ -16,17 +16,26 @@ export const getVariable = <T extends VariableModel = VariableModel>(
|
|||||||
return state.templating.variables[uuid] as T;
|
return state.templating.variables[uuid] as T;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getFilteredVariables = (filter: (model: VariableModel) => boolean, state: StoreState = getState()) => {
|
||||||
|
return Object.values(state.templating.variables).filter(filter);
|
||||||
|
};
|
||||||
|
|
||||||
export const getVariableWithName = (name: string, state: StoreState = getState()) => {
|
export const getVariableWithName = (name: string, state: StoreState = getState()) => {
|
||||||
return Object.values(state.templating.variables).find(variable => variable.name === name);
|
return Object.values(state.templating.variables).find(variable => variable.name === name);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getVariables = (state: StoreState = getState()): VariableModel[] => {
|
export const getVariables = (state: StoreState = getState()): VariableModel[] => {
|
||||||
return Object.values(state.templating.variables).filter(variable => variable.uuid! !== EMPTY_UUID);
|
return getFilteredVariables(variable => variable.uuid! !== EMPTY_UUID, state);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getVariableClones = (state: StoreState = getState(), includeEmptyUuid = false): VariableModel[] => {
|
export const getVariableClones = (state: StoreState = getState(), includeEmptyUuid = false): VariableModel[] => {
|
||||||
const variables = Object.values(state.templating.variables)
|
const variables = getFilteredVariables(
|
||||||
.filter(variable => (includeEmptyUuid ? true : variable.uuid !== EMPTY_UUID))
|
variable => (includeEmptyUuid ? true : variable.uuid !== EMPTY_UUID),
|
||||||
.map(variable => cloneDeep(variable));
|
state
|
||||||
|
).map(variable => cloneDeep(variable));
|
||||||
return variables.sort((s1, s2) => s1.index! - s2.index!);
|
return variables.sort((s1, s2) => s1.index! - s2.index!);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getNewVariabelIndex = (state: StoreState = getState()): number => {
|
||||||
|
return Object.values(state.templating.variables).length;
|
||||||
|
};
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
import $ from 'jquery';
|
import $ from 'jquery';
|
||||||
import { MetricsPanelCtrl } from 'app/plugins/sdk';
|
import { MetricsPanelCtrl } from 'app/plugins/sdk';
|
||||||
import config from 'app/core/config';
|
import config, { getConfig } from 'app/core/config';
|
||||||
import { transformDataToTable } from './transformers';
|
import { transformDataToTable } from './transformers';
|
||||||
import { tablePanelEditor } from './editor';
|
import { tablePanelEditor } from './editor';
|
||||||
import { columnOptionsTab } from './column_options';
|
import { columnOptionsTab } from './column_options';
|
||||||
@ -9,6 +9,8 @@ import { TableRenderer } from './renderer';
|
|||||||
import { isTableData, PanelEvents, PanelPlugin } from '@grafana/data';
|
import { isTableData, PanelEvents, PanelPlugin } from '@grafana/data';
|
||||||
import { TemplateSrv } from 'app/features/templating/template_srv';
|
import { TemplateSrv } from 'app/features/templating/template_srv';
|
||||||
import { CoreEvents } from 'app/types';
|
import { CoreEvents } from 'app/types';
|
||||||
|
import { dispatch } from 'app/store/store';
|
||||||
|
import { applyFilterFromTable } from 'app/features/variables/adhoc/actions';
|
||||||
|
|
||||||
export class TablePanelCtrl extends MetricsPanelCtrl {
|
export class TablePanelCtrl extends MetricsPanelCtrl {
|
||||||
static templateUrl = 'module.html';
|
static templateUrl = 'module.html';
|
||||||
@ -257,7 +259,11 @@ export class TablePanelCtrl extends MetricsPanelCtrl {
|
|||||||
operator: filterData.operator,
|
operator: filterData.operator,
|
||||||
};
|
};
|
||||||
|
|
||||||
ctrl.variableSrv.setAdhocFilter(options);
|
if (getConfig().featureToggles.newVariables) {
|
||||||
|
dispatch(applyFilterFromTable(options));
|
||||||
|
} else {
|
||||||
|
ctrl.variableSrv.setAdhocFilter(options);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
elem.on('click', '.table-panel-page-link', switchPage);
|
elem.on('click', '.table-panel-page-link', switchPage);
|
||||||
|
@ -69,8 +69,11 @@ export const reduxTester = <State>(args?: ReduxTesterArguments<State>): ReduxTes
|
|||||||
dispatchedActions.length = 0;
|
dispatchedActions.length = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
store.dispatch(action);
|
if (store === null) {
|
||||||
|
throw new Error('Store was not setup properly');
|
||||||
|
}
|
||||||
|
|
||||||
|
store.dispatch(action);
|
||||||
return instance;
|
return instance;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -82,8 +85,11 @@ export const reduxTester = <State>(args?: ReduxTesterArguments<State>): ReduxTes
|
|||||||
dispatchedActions.length = 0;
|
dispatchedActions.length = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
await store.dispatch(action);
|
if (store === null) {
|
||||||
|
throw new Error('Store was not setup properly');
|
||||||
|
}
|
||||||
|
|
||||||
|
await store.dispatch(action);
|
||||||
return instance;
|
return instance;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user