Commit a7a14064 by Marcus Andersson Committed by GitHub

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>
parent 6c9d8336
......@@ -16,6 +16,7 @@ import { createCustomVariableAdapter } from '../variables/custom/adapter';
import { createTextBoxVariableAdapter } from '../variables/textbox/adapter';
import { createConstantVariableAdapter } from '../variables/constant/adapter';
import { createDataSourceVariableAdapter } from '../variables/datasource/adapter';
import { createAdHocVariableAdapter } from '../variables/adhoc/adapter';
import { createIntervalVariableAdapter } from '../variables/interval/adapter';
coreModule.factory('templateSrv', () => templateSrv);
......@@ -36,4 +37,5 @@ variableAdapters.set('custom', createCustomVariableAdapter());
variableAdapters.set('textbox', createTextBoxVariableAdapter());
variableAdapters.set('constant', createConstantVariableAdapter());
variableAdapters.set('datasource', createDataSourceVariableAdapter());
variableAdapters.set('adhoc', createAdHocVariableAdapter());
variableAdapters.set('interval', createIntervalVariableAdapter());
......@@ -3,9 +3,10 @@ import _ from 'lodash';
import { variableRegex } from 'app/features/templating/variable';
import { escapeHtml } from 'app/core/utils/text';
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 { getConfig } from 'app/core/config';
import { isAdHoc } from '../variables/guard';
function luceneEscape(value: string) {
return value.replace(/([\!\*\+\-\=<>\s\&\|\(\)\[\]\{\}\^\~\?\:\\/"])/g, '\\$1');
......@@ -79,20 +80,12 @@ export class TemplateSrv {
getAdhocFilters(datasourceName: string) {
let filters: any = [];
if (this.variables) {
for (let i = 0; i < this.variables.length; i++) {
const variable = this.variables[i];
if (variable.type !== 'adhoc') {
continue;
}
// null is the "default" datasource
if (variable.datasource === null || variable.datasource === datasourceName) {
for (const variable of this.getAdHocVariables()) {
if (variable.datasource === null || variable.datasource === datasourceName) {
filters = filters.concat(variable.filters);
} else if (variable.datasource.indexOf('$') === 0) {
if (this.replace(variable.datasource) === datasourceName) {
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];
};
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();
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
);
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;
};
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);
},
};
};
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>
);
}
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';
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>
);
};
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} />;
};
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,
};
}
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;
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: '',
};
}
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);
}
import { reduxTester } from '../../../../test/core/redux/reduxTester';
import { TemplatingState } from '../state/reducers';
import { getTemplatingRootReducer, variableMockBuilder } from '../state/helpers';
import { getTemplatingRootReducer } from '../state/helpers';
import { initDashboardTemplating } from '../state/actions';
import { toVariableIdentifier, toVariablePayload } from '../state/types';
import { variableAdapters } from '../adapters';
......@@ -15,6 +15,7 @@ import { getMockPlugin } from '../../plugins/__mocks__/pluginMocks';
import { createDataSourceOptions } from './reducer';
import { setCurrentVariableValue } from '../state/sharedReducer';
import { changeVariableEditorExtended } from '../editor/reducer';
import * as variableBuilder from '../shared/testing/builders';
describe('data source actions', () => {
variableAdapters.set('datasource', createDataSourceVariableAdapter());
......@@ -40,10 +41,12 @@ describe('data source actions', () => {
const getMetricSourcesMock = jest.fn().mockResolvedValue(sources);
const getDatasourceSrvMock = jest.fn().mockReturnValue({ getMetricSources: getMetricSourcesMock });
const dependencies: DataSourceVariableActionDependencies = { getDatasourceSrv: getDatasourceSrvMock };
const datasource = variableMockBuilder('datasource')
.withUuid('0')
const datasource = variableBuilder
.datasource()
.withUUID('0')
.withQuery('mock-data-id')
.create();
.build();
const tester = await reduxTester<{ templating: TemplatingState }>()
.givenRootReducer(getTemplatingRootReducer())
.whenActionIsDispatched(initDashboardTemplating([datasource]))
......@@ -90,11 +93,12 @@ describe('data source actions', () => {
const getMetricSourcesMock = jest.fn().mockResolvedValue(sources);
const getDatasourceSrvMock = jest.fn().mockReturnValue({ getMetricSources: getMetricSourcesMock });
const dependencies: DataSourceVariableActionDependencies = { getDatasourceSrv: getDatasourceSrvMock };
const datasource = variableMockBuilder('datasource')
.withUuid('0')
const datasource = variableBuilder
.datasource()
.withUUID('0')
.withQuery('mock-data-id')
.withRegEx('/.*(second-name).*/')
.create();
.build();
const tester = await reduxTester<{ templating: TemplatingState }>()
.givenRootReducer(getTemplatingRootReducer())
.whenActionIsDispatched(initDashboardTemplating([datasource]))
......
import { ThunkResult } from '../../../types';
import { getVariable, getVariables } from '../state/selectors';
import { getVariable, getVariables, getNewVariabelIndex } from '../state/selectors';
import {
changeVariableNameFailed,
changeVariableNameSucceeded,
......@@ -82,7 +82,7 @@ export const switchToNewMode = (): ThunkResult<void> => (dispatch, getState) =>
const uuid = EMPTY_UUID;
const global = false;
const model = cloneDeep(variableAdapters.get(type).initialState);
const index = Object.values(getState().templating.variables).length;
const index = getNewVariabelIndex(getState());
const identifier = { type, uuid };
dispatch(
addVariable(
......
import { QueryVariableModel, VariableModel } from '../templating/variable';
import { QueryVariableModel, VariableModel, AdHocVariableModel } from '../templating/variable';
export const isQuery = (model: VariableModel): model is QueryVariableModel => {
return model.type === 'query';
};
export const isAdHoc = (model: VariableModel): model is AdHocVariableModel => {
return model.type === 'adhoc';
};
import { getTemplatingRootReducer, variableMockBuilder } from '../state/helpers';
import { getTemplatingRootReducer } from '../state/helpers';
import { reduxTester } from '../../../../test/core/redux/reduxTester';
import { TemplatingState } from '../state/reducers';
import { initDashboardTemplating } from '../state/actions';
......@@ -17,16 +17,18 @@ import { Emitter } from 'app/core/core';
import { AppEvents, dateTime } from '@grafana/data';
import { getTimeSrv, setTimeSrv, TimeSrv } from '../../dashboard/services/TimeSrv';
import { TemplateSrv } from '../../templating/template_srv';
import * as variableBuilder from '../shared/testing/builders';
describe('interval actions', () => {
variableAdapters.set('interval', createIntervalVariableAdapter());
describe('when updateIntervalVariableOptions is dispatched', () => {
it('then correct actions are dispatched', async () => {
const interval = variableMockBuilder('interval')
.withUuid('0')
const interval = variableBuilder
.interval()
.withUUID('0')
.withQuery('1s,1m,1h,1d')
.withAuto(false)
.create();
.build();
const tester = await reduxTester<{ templating: TemplatingState }>()
.givenRootReducer(getTemplatingRootReducer())
......@@ -60,12 +62,13 @@ describe('interval actions', () => {
} as unknown) as TimeSrv;
const originalTimeSrv = getTimeSrv();
setTimeSrv(timeSrvMock);
const interval = variableMockBuilder('interval')
.withUuid('0')
const interval = variableBuilder
.interval()
.withUUID('0')
.withQuery('1s,1m,1h,1d')
.withAuto(true)
.withAutoMin('1') // illegal interval string
.create();
.build();
const appEventMock = ({
emit: jest.fn(),
} as unknown) as Emitter;
......@@ -88,10 +91,11 @@ describe('interval actions', () => {
describe('when updateAutoValue is dispatched', () => {
describe('and auto is false', () => {
it('then no dependencies are called', async () => {
const interval = variableMockBuilder('interval')
.withUuid('0')
const interval = variableBuilder
.interval()
.withUUID('0')
.withAuto(false)
.create();
.build();
const dependencies: UpdateAutoValueDependencies = {
kbn: {
......@@ -127,13 +131,14 @@ describe('interval actions', () => {
describe('and auto is true', () => {
it('then correct dependencies are called', async () => {
const interval = variableMockBuilder('interval')
.withUuid('0')
const interval = variableBuilder
.interval()
.withUUID('0')
.withName('intervalName')
.withAuto(true)
.withAutoCount(33)
.withAutoMin('13s')
.create();
.build();
const timeRangeMock = jest.fn().mockReturnValue({
from: '2001-01-01',
......
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;
}
}
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);
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;
}
}
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;
}
}
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;
}
}
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;
}
}
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;
}
}
import { AnyAction } from 'redux';
import { UrlQueryMap } from '@grafana/runtime';
import { getTemplatingAndLocationRootReducer, getTemplatingRootReducer, variableMockBuilder } from './helpers';
import { getTemplatingAndLocationRootReducer, getTemplatingRootReducer } from './helpers';
import { variableAdapters } from '../adapters';
import { createQueryVariableAdapter } from '../query/adapter';
import { createCustomVariableAdapter } from '../custom/adapter';
......@@ -26,6 +26,7 @@ import { VariableRefresh } from '../../templating/variable';
import { DashboardModel } from '../../dashboard/state';
import { DashboardState } from '../../../types';
import { dateTime, TimeRange } from '@grafana/data';
import * as variableBuilder from '../shared/testing/builders';
describe('shared actions', () => {
describe('when initDashboardTemplating is dispatched', () => {
......@@ -34,11 +35,11 @@ describe('shared actions', () => {
variableAdapters.set('custom', createCustomVariableAdapter());
variableAdapters.set('textbox', createTextBoxVariableAdapter());
variableAdapters.set('constant', createConstantVariableAdapter());
const query = variableMockBuilder('query').create();
const constant = variableMockBuilder('constant').create();
const datasource = variableMockBuilder('datasource').create();
const custom = variableMockBuilder('custom').create();
const textbox = variableMockBuilder('textbox').create();
const query = variableBuilder.query().build();
const constant = variableBuilder.constant().build();
const datasource = variableBuilder.datasource().build();
const custom = variableBuilder.custom().build();
const textbox = variableBuilder.textbox().build();
const list = [query, constant, datasource, custom, textbox];
reduxTester<{ templating: TemplatingState }>()
......@@ -85,11 +86,11 @@ describe('shared actions', () => {
variableAdapters.set('custom', createCustomVariableAdapter());
variableAdapters.set('textbox', createTextBoxVariableAdapter());
variableAdapters.set('constant', createConstantVariableAdapter());
const query = variableMockBuilder('query').create();
const constant = variableMockBuilder('constant').create();
const datasource = variableMockBuilder('datasource').create();
const custom = variableMockBuilder('custom').create();
const textbox = variableMockBuilder('textbox').create();
const query = variableBuilder.query().build();
const constant = variableBuilder.constant().build();
const datasource = variableBuilder.datasource().build();
const custom = variableBuilder.custom().build();
const textbox = variableBuilder.textbox().build();
const list = [query, constant, datasource, custom, textbox];
const tester = await reduxTester<{ templating: TemplatingState; location: { query: UrlQueryMap } }>({
......@@ -145,11 +146,12 @@ describe('shared actions', () => {
${undefined} | ${[undefined]}
`('and urlValue is $urlValue then correct actions are dispatched', async ({ urlValue, expected }) => {
variableAdapters.set('custom', createCustomVariableAdapter());
const custom = variableMockBuilder('custom')
.withUuid('0')
const custom = variableBuilder
.custom()
.withUUID('0')
.withOptions('A', 'B', 'C')
.withCurrent('A')
.create();
.build();
const tester = await reduxTester<{ templating: TemplatingState }>()
.givenRootReducer(getTemplatingRootReducer())
......@@ -182,19 +184,19 @@ describe('shared actions', () => {
let custom;
if (!withOptions) {
custom = variableMockBuilder('custom')
.withUuid('0')
custom = variableBuilder
.custom()
.withUUID('0')
.withCurrent(withCurrent)
.create();
custom.options = undefined;
}
if (withOptions) {
custom = variableMockBuilder('custom')
.withUuid('0')
.withoutOptions()
.build();
} else {
custom = variableBuilder
.custom()
.withUUID('0')
.withOptions(...withOptions)
.withCurrent(withCurrent)
.create();
.build();
}
const tester = await reduxTester<{ templating: TemplatingState }>()
......@@ -238,21 +240,21 @@ describe('shared actions', () => {
let custom;
if (!withOptions) {
custom = variableMockBuilder('custom')
.withUuid('0')
custom = variableBuilder
.custom()
.withUUID('0')
.withMulti()
.withCurrent(withCurrent)
.create();
custom.options = undefined;
}
if (withOptions) {
custom = variableMockBuilder('custom')
.withUuid('0')
.withoutOptions()
.build();
} else {
custom = variableBuilder
.custom()
.withUUID('0')
.withMulti()
.withOptions(...withOptions)
.withCurrent(withCurrent)
.create();
.build();
}
const tester = await reduxTester<{ templating: TemplatingState }>()
......@@ -314,34 +316,37 @@ describe('shared actions', () => {
variableAdapters.set('constant', createConstantVariableAdapter());
// initial variable state
const initialVariable = variableMockBuilder('interval')
.withUuid('0')
const initialVariable = variableBuilder
.interval()
.withUUID('0')
.withName('interval-0')
.withOptions('1m', '10m', '30m', '1h', '6h', '12h', '1d', '7d', '14d', '30d')
.withCurrent('1m')
.withRefresh(VariableRefresh.onTimeRangeChanged)
.create();
.build();
// the constant variable should be filtered out
const constant = variableMockBuilder('constant')
.withUuid('1')
const constant = variableBuilder
.constant()
.withUUID('1')
.withName('constant-1')
.withOptions('a constant')
.withCurrent('a constant')
.create();
.build();
const initialState = {
templating: { variables: { '0': { ...initialVariable }, '1': { ...constant } } },
dashboard,
};
// updated variable state
const updatedVariable = variableMockBuilder('interval')
.withUuid('0')
const updatedVariable = variableBuilder
.interval()
.withUUID('0')
.withName('interval-0')
.withOptions('1m')
.withCurrent('1m')
.withRefresh(VariableRefresh.onTimeRangeChanged)
.create();
.build();
const variable = args.update ? { ...updatedVariable } : { ...initialVariable };
const state = { templating: { variables: { '0': variable, '1': { ...constant } } }, dashboard };
......
import { combineReducers } from '@reduxjs/toolkit';
import cloneDeep from 'lodash/cloneDeep';
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 { optionsPickerReducer } from '../pickers/OptionsPicker/reducer';
import { variableEditorReducer } from '../editor/reducer';
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 = (
noOfVariables: number,
......@@ -61,90 +61,16 @@ export const getVariableTestContext = <Model extends VariableModel>(
return { initialState };
};
export const variableMockBuilder = (type: VariableType) => {
const initialState = variableAdapters.contains(type)
? cloneDeep(variableAdapters.get(type).initialState)
: { name: type, type, label: '', hide: VariableHide.dontHide, skipUrlSync: false };
const { uuid, index, global, ...rest } = initialState;
const model = { ...rest, name: type };
const withUuid = (uuid: string) => {
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 getRootReducer = () =>
combineReducers({
location: locationReducer,
dashboard: dashboardReducer,
templating: combineReducers({
optionsPicker: optionsPickerReducer,
editor: variableEditorReducer,
variables: variablesReducer,
}),
});
export const getTemplatingRootReducer = () =>
combineReducers({
......
import { UrlQueryMap } from '@grafana/runtime';
import { getTemplatingRootReducer, variableMockBuilder } from './helpers';
import { getTemplatingRootReducer } from './helpers';
import { variableAdapters } from '../adapters';
import { createQueryVariableAdapter } from '../query/adapter';
import { createCustomVariableAdapter } from '../custom/adapter';
......@@ -11,6 +11,7 @@ import { resolveInitLock, setCurrentVariableValue } from './sharedReducer';
import { toVariableIdentifier, toVariablePayload } from './types';
import { VariableRefresh } from '../../templating/variable';
import { updateVariableOptions } from '../query/reducer';
import * as variableBuilder from '../shared/testing/builders';
jest.mock('app/features/dashboard/services/TimeSrv', () => ({
getTimeSrv: jest.fn().mockReturnValue({
......@@ -67,28 +68,31 @@ describe('processVariable', () => {
const getAndSetupProcessVariableContext = () => {
variableAdapters.set('custom', createCustomVariableAdapter());
variableAdapters.set('query', createQueryVariableAdapter());
const custom = variableMockBuilder('custom')
.withUuid('0')
const custom = variableBuilder
.custom()
.withUUID('0')
.withQuery('A,B,C')
.withOptions('A', 'B', 'C')
.withCurrent('A')
.create();
.build();
const queryDependsOnCustom = variableMockBuilder('query')
.withUuid('1')
const queryDependsOnCustom = variableBuilder
.query()
.withUUID('1')
.withName('queryDependsOnCustom')
.withQuery('$custom.*')
.withOptions('AA', 'AB', 'AC')
.withCurrent('AA')
.create();
.build();
const queryNoDepends = variableMockBuilder('query')
.withUuid('2')
const queryNoDepends = variableBuilder
.query()
.withUUID('2')
.withName('queryNoDepends')
.withQuery('*')
.withOptions('A', 'B', 'C')
.withCurrent('A')
.create();
.build();
const list = [custom, queryDependsOnCustom, queryNoDepends];
......
......@@ -12,8 +12,8 @@ export interface TemplatingState {
export default {
templating: combineReducers({
optionsPicker: optionsPickerReducer,
editor: variableEditorReducer,
variables: variablesReducer,
optionsPicker: optionsPickerReducer,
}),
};
......@@ -16,17 +16,26 @@ export const getVariable = <T extends VariableModel = VariableModel>(
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()) => {
return Object.values(state.templating.variables).find(variable => variable.name === name);
};
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[] => {
const variables = Object.values(state.templating.variables)
.filter(variable => (includeEmptyUuid ? true : variable.uuid !== EMPTY_UUID))
.map(variable => cloneDeep(variable));
const variables = getFilteredVariables(
variable => (includeEmptyUuid ? true : variable.uuid !== EMPTY_UUID),
state
).map(variable => cloneDeep(variable));
return variables.sort((s1, s2) => s1.index! - s2.index!);
};
export const getNewVariabelIndex = (state: StoreState = getState()): number => {
return Object.values(state.templating.variables).length;
};
import _ from 'lodash';
import $ from 'jquery';
import { MetricsPanelCtrl } from 'app/plugins/sdk';
import config from 'app/core/config';
import config, { getConfig } from 'app/core/config';
import { transformDataToTable } from './transformers';
import { tablePanelEditor } from './editor';
import { columnOptionsTab } from './column_options';
......@@ -9,6 +9,8 @@ import { TableRenderer } from './renderer';
import { isTableData, PanelEvents, PanelPlugin } from '@grafana/data';
import { TemplateSrv } from 'app/features/templating/template_srv';
import { CoreEvents } from 'app/types';
import { dispatch } from 'app/store/store';
import { applyFilterFromTable } from 'app/features/variables/adhoc/actions';
export class TablePanelCtrl extends MetricsPanelCtrl {
static templateUrl = 'module.html';
......@@ -257,7 +259,11 @@ export class TablePanelCtrl extends MetricsPanelCtrl {
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);
......
......@@ -69,8 +69,11 @@ export const reduxTester = <State>(args?: ReduxTesterArguments<State>): ReduxTes
dispatchedActions.length = 0;
}
store.dispatch(action);
if (store === null) {
throw new Error('Store was not setup properly');
}
store.dispatch(action);
return instance;
};
......@@ -82,8 +85,11 @@ export const reduxTester = <State>(args?: ReduxTesterArguments<State>): ReduxTes
dispatchedActions.length = 0;
}
await store.dispatch(action);
if (store === null) {
throw new Error('Store was not setup properly');
}
await store.dispatch(action);
return instance;
};
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment