From a7a14064155f66576719fa5d9deea5b108a7a047 Mon Sep 17 00:00:00 2001 From: Marcus Andersson Date: Mon, 23 Mar 2020 09:00:36 +0100 Subject: [PATCH] Variables: migrates ad hoc variable type to react/redux. (#22784) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- public/app/features/templating/all.ts | 2 + .../app/features/templating/template_srv.ts | 31 +- .../variables/adhoc/AdHocVariableEditor.tsx | 85 +++ .../features/variables/adhoc/actions.test.ts | 577 ++++++++++++++++++ .../app/features/variables/adhoc/actions.ts | 163 +++++ .../app/features/variables/adhoc/adapter.ts | 37 ++ .../adhoc/picker/AdHocFilterBuilder.tsx | 77 +++ .../variables/adhoc/picker/AdHocPicker.tsx | 142 +++++ .../adhoc/picker/ConditionSegment.tsx | 13 + .../adhoc/picker/OperatorSegment.tsx | 17 + .../features/variables/adhoc/reducer.test.ts | 220 +++++++ .../app/features/variables/adhoc/reducer.ts | 57 ++ .../variables/adhoc/urlParser.test.ts | 101 +++ .../app/features/variables/adhoc/urlParser.ts | 52 ++ .../variables/datasource/actions.test.ts | 18 +- .../app/features/variables/editor/actions.ts | 4 +- public/app/features/variables/guard.ts | 6 +- .../variables/interval/actions.test.ts | 31 +- .../shared/testing/adHocVariableBuilder.ts | 14 + .../variables/shared/testing/builders.ts | 20 + .../testing/datasourceVariableBuilder.ts | 14 + .../shared/testing/intervalVariableBuilder.ts | 24 + .../shared/testing/multiVariableBuilder.ts | 9 + .../shared/testing/optionsVariableBuilder.ts | 35 ++ .../shared/testing/variableBuilder.ts | 25 + .../features/variables/state/actions.test.ts | 91 +-- .../app/features/variables/state/helpers.ts | 100 +-- .../variables/state/processVariable.test.ts | 24 +- .../app/features/variables/state/reducers.ts | 2 +- .../app/features/variables/state/selectors.ts | 17 +- public/app/plugins/panel/table/module.ts | 10 +- public/test/core/redux/reduxTester.ts | 10 +- 32 files changed, 1842 insertions(+), 186 deletions(-) create mode 100644 public/app/features/variables/adhoc/AdHocVariableEditor.tsx create mode 100644 public/app/features/variables/adhoc/actions.test.ts create mode 100644 public/app/features/variables/adhoc/actions.ts create mode 100644 public/app/features/variables/adhoc/adapter.ts create mode 100644 public/app/features/variables/adhoc/picker/AdHocFilterBuilder.tsx create mode 100644 public/app/features/variables/adhoc/picker/AdHocPicker.tsx create mode 100644 public/app/features/variables/adhoc/picker/ConditionSegment.tsx create mode 100644 public/app/features/variables/adhoc/picker/OperatorSegment.tsx create mode 100644 public/app/features/variables/adhoc/reducer.test.ts create mode 100644 public/app/features/variables/adhoc/reducer.ts create mode 100644 public/app/features/variables/adhoc/urlParser.test.ts create mode 100644 public/app/features/variables/adhoc/urlParser.ts create mode 100644 public/app/features/variables/shared/testing/adHocVariableBuilder.ts create mode 100644 public/app/features/variables/shared/testing/builders.ts create mode 100644 public/app/features/variables/shared/testing/datasourceVariableBuilder.ts create mode 100644 public/app/features/variables/shared/testing/intervalVariableBuilder.ts create mode 100644 public/app/features/variables/shared/testing/multiVariableBuilder.ts create mode 100644 public/app/features/variables/shared/testing/optionsVariableBuilder.ts create mode 100644 public/app/features/variables/shared/testing/variableBuilder.ts diff --git a/public/app/features/templating/all.ts b/public/app/features/templating/all.ts index 9953be38e50..9448bb1c25e 100644 --- a/public/app/features/templating/all.ts +++ b/public/app/features/templating/all.ts @@ -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()); diff --git a/public/app/features/templating/template_srv.ts b/public/app/features/templating/template_srv.ts index 736df956054..38efa06d816 100644 --- a/public/app/features/templating/template_srv.ts +++ b/public/app/features/templating/template_srv.ts @@ -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(); diff --git a/public/app/features/variables/adhoc/AdHocVariableEditor.tsx b/public/app/features/variables/adhoc/AdHocVariableEditor.tsx new file mode 100644 index 00000000000..7617dfac8c3 --- /dev/null +++ b/public/app/features/variables/adhoc/AdHocVariableEditor.tsx @@ -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 {} + +interface ConnectedProps { + editor: VariableEditorState; +} + +interface DispatchProps { + initAdHocVariableEditor: typeof initAdHocVariableEditor; + changeVariableDatasource: typeof changeVariableDatasource; +} + +type Props = OwnProps & ConnectedProps & DispatchProps; + +export class AdHocVariableEditorUnConnected extends PureComponent { + componentDidMount() { + this.props.initAdHocVariableEditor(); + } + + onDatasourceChanged = (event: React.ChangeEvent) => { + this.props.changeVariableDatasource(event.target.value); + }; + + render() { + const { variable, editor } = this.props; + const dataSources = editor.extended?.dataSources ?? []; + const infoText = editor.extended?.infoText ?? null; + + return ( + <> +
+
Options
+
+ Data source +
+ +
+
+
+ + {infoText && ( +
+ {infoText} +
+ )} + + ); + } +} + +const mapStateToProps: MapStateToProps = (state, ownProps) => ({ + editor: state.templating.editor as VariableEditorState, +}); + +const mapDispatchToProps: MapDispatchToProps = { + initAdHocVariableEditor, + changeVariableDatasource, +}; + +export const AdHocVariableEditor = connectWithStore( + AdHocVariableEditorUnConnected, + mapStateToProps, + mapDispatchToProps +); diff --git a/public/app/features/variables/adhoc/actions.test.ts b/public/app/features/variables/adhoc/actions.test.ts new file mode 100644 index 00000000000..633686975e5 --- /dev/null +++ b/public/app/features/variables/adhoc/actions.test.ts @@ -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() + .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() + .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() + .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() + .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() + .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() + .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() + .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() + .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() + .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() + .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() + .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() + .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() + .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: '', + }; +} diff --git a/public/app/features/variables/adhoc/actions.ts b/public/app/features/variables/adhoc/actions.ts new file mode 100644 index 00000000000..c9b826f5bf6 --- /dev/null +++ b/public/app/features/variables/adhoc/actions.ts @@ -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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => 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 => { + 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(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; +}; diff --git a/public/app/features/variables/adhoc/adapter.ts b/public/app/features/variables/adhoc/adapter.ts new file mode 100644 index 00000000000..16b1f753f7a --- /dev/null +++ b/public/app/features/variables/adhoc/adapter.ts @@ -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 => { + 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); + }, + }; +}; diff --git a/public/app/features/variables/adhoc/picker/AdHocFilterBuilder.tsx b/public/app/features/variables/adhoc/picker/AdHocFilterBuilder.tsx new file mode 100644 index 00000000000..be429df98ca --- /dev/null +++ b/public/app/features/variables/adhoc/picker/AdHocFilterBuilder.tsx @@ -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>>; + onLoadValues: (key: string) => Promise>>; + onCompleted: (filter: AdHocVariableFilter) => void; + appendBefore?: React.ReactNode; +} + +export const AdHocFilterBuilder: FC = ({ appendBefore, onCompleted, onLoadKeys, onLoadValues }) => { + const [key, setKey] = useState(null); + const [operator, setOperator] = useState('='); + + if (key === null) { + return ( +
+ setKey(value ?? '')} + loadOptions={onLoadKeys} + /> +
+ ); + } + + return ( + + {appendBefore} +
+ setKey(value ?? '')} + loadOptions={onLoadKeys} + /> +
+
+ setOperator(value ?? '')} /> +
+
+ { + onCompleted({ + value: value ?? '', + operator: operator, + condition: '', + key: key, + }); + setKey(null); + setOperator('='); + }} + loadOptions={() => onLoadValues(key)} + /> +
+
+ ); +}; + +function filterAddButton(key: string | null): ReactElement | undefined { + if (key !== null) { + return undefined; + } + + return ( + + + + ); +} diff --git a/public/app/features/variables/adhoc/picker/AdHocPicker.tsx b/public/app/features/variables/adhoc/picker/AdHocPicker.tsx new file mode 100644 index 00000000000..a779321ee8d --- /dev/null +++ b/public/app/features/variables/adhoc/picker/AdHocPicker.tsx @@ -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 {} + +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 { + onChange = (index: number, prop: string) => (key: SelectableValue) => { + 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 ( +
+ {this.renderFilters(filters)} + 0 ? : null} + onLoadKeys={this.fetchFilterKeys} + onLoadValues={this.fetchFilterValues} + onCompleted={this.appendFilterToVariable} + /> +
+ ); + } + + renderFilters(filters: AdHocVariableFilter[]) { + return filters.reduce((segments: ReactNode[], filter, index) => { + if (segments.length > 0) { + segments.push(); + } + segments.push(this.renderFilterSegments(filter, index)); + return segments; + }, []); + } + + renderFilterSegments(filter: AdHocVariableFilter, index: number) { + return ( + +
+ +
+
+ +
+
+ this.fetchFilterValues(filter.key)} + /> +
+
+ ); + } +} + +const mapDispatchToProps: MapDispatchToProps = { + addFilter, + removeFilter, + changeFilter, +}; + +const mapStateToProps: MapStateToProps = state => ({}); + +export const AdHocPicker = connect(mapStateToProps, mapDispatchToProps)(AdHocPickerUnconnected); +AdHocPicker.displayName = 'AdHocPicker'; diff --git a/public/app/features/variables/adhoc/picker/ConditionSegment.tsx b/public/app/features/variables/adhoc/picker/ConditionSegment.tsx new file mode 100644 index 00000000000..537b0ddb607 --- /dev/null +++ b/public/app/features/variables/adhoc/picker/ConditionSegment.tsx @@ -0,0 +1,13 @@ +import React, { FC } from 'react'; + +interface Props { + label: string; +} + +export const ConditionSegment: FC = ({ label }) => { + return ( +
+ {label} +
+ ); +}; diff --git a/public/app/features/variables/adhoc/picker/OperatorSegment.tsx b/public/app/features/variables/adhoc/picker/OperatorSegment.tsx new file mode 100644 index 00000000000..73aa8b91b0e --- /dev/null +++ b/public/app/features/variables/adhoc/picker/OperatorSegment.tsx @@ -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) => void; +} + +const options = ['=', '!=', '<', '>', '=~', '!~'].map>(value => ({ + label: value, + value, +})); + +export const OperatorSegment: FC = ({ value, onChange }) => { + return ; +}; diff --git a/public/app/features/variables/adhoc/reducer.test.ts b/public/app/features/variables/adhoc/reducer.test.ts new file mode 100644 index 00000000000..c06334dc606 --- /dev/null +++ b/public/app/features/variables/adhoc/reducer.test.ts @@ -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() + .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() + .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() + .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() + .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() + .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() + .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() + .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() + .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() + .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, + }; +} diff --git a/public/app/features/variables/adhoc/reducer.ts b/public/app/features/variables/adhoc/reducer.ts new file mode 100644 index 00000000000..54a09736289 --- /dev/null +++ b/public/app/features/variables/adhoc/reducer.ts @@ -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>) => { + const instanceState = getInstanceState(state, action.payload.uuid); + instanceState.filters.push(action.payload.data); + }, + filterRemoved: (state: VariablesState, action: PayloadAction>) => { + const instanceState = getInstanceState(state, action.payload.uuid); + const index = action.payload.data; + + instanceState.filters.splice(index, 1); + }, + filterUpdated: (state: VariablesState, action: PayloadAction>) => { + const instanceState = getInstanceState(state, action.payload.uuid); + const { filter, index } = action.payload.data; + + instanceState.filters[index] = filter; + }, + filtersRestored: (state: VariablesState, action: PayloadAction>) => { + const instanceState = getInstanceState(state, action.payload.uuid); + instanceState.filters = action.payload.data; + }, + }, +}); + +export const { filterAdded, filterRemoved, filterUpdated, filtersRestored } = adHocVariableSlice.actions; +export const adHocVariableReducer = adHocVariableSlice.reducer; diff --git a/public/app/features/variables/adhoc/urlParser.test.ts b/public/app/features/variables/adhoc/urlParser.test.ts new file mode 100644 index 00000000000..664910bf649 --- /dev/null +++ b/public/app/features/variables/adhoc/urlParser.test.ts @@ -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: '', + }; +} diff --git a/public/app/features/variables/adhoc/urlParser.ts b/public/app/features/variables/adhoc/urlParser.ts new file mode 100644 index 00000000000..201d6e1d5cc --- /dev/null +++ b/public/app/features/variables/adhoc/urlParser.ts @@ -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); +} diff --git a/public/app/features/variables/datasource/actions.test.ts b/public/app/features/variables/datasource/actions.test.ts index ab4b8d0f02d..87039451b14 100644 --- a/public/app/features/variables/datasource/actions.test.ts +++ b/public/app/features/variables/datasource/actions.test.ts @@ -1,6 +1,6 @@ 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])) diff --git a/public/app/features/variables/editor/actions.ts b/public/app/features/variables/editor/actions.ts index 7b37fcab9bc..141a502ce04 100644 --- a/public/app/features/variables/editor/actions.ts +++ b/public/app/features/variables/editor/actions.ts @@ -1,5 +1,5 @@ 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 => (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( diff --git a/public/app/features/variables/guard.ts b/public/app/features/variables/guard.ts index 8b93cc5764e..99210dad4a0 100644 --- a/public/app/features/variables/guard.ts +++ b/public/app/features/variables/guard.ts @@ -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 => { return model.type === 'query'; }; + +export const isAdHoc = (model: VariableModel): model is AdHocVariableModel => { + return model.type === 'adhoc'; +}; diff --git a/public/app/features/variables/interval/actions.test.ts b/public/app/features/variables/interval/actions.test.ts index b12ce662d3b..006e9d4b14a 100644 --- a/public/app/features/variables/interval/actions.test.ts +++ b/public/app/features/variables/interval/actions.test.ts @@ -1,4 +1,4 @@ -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', diff --git a/public/app/features/variables/shared/testing/adHocVariableBuilder.ts b/public/app/features/variables/shared/testing/adHocVariableBuilder.ts new file mode 100644 index 00000000000..5c8b042989b --- /dev/null +++ b/public/app/features/variables/shared/testing/adHocVariableBuilder.ts @@ -0,0 +1,14 @@ +import { AdHocVariableModel, AdHocVariableFilter } from 'app/features/templating/variable'; +import { VariableBuilder } from './variableBuilder'; + +export class AdHocVariableBuilder extends VariableBuilder { + withDatasource(datasource: string) { + this.variable.datasource = datasource; + return this; + } + + withFilters(filters: AdHocVariableFilter[]) { + this.variable.filters = filters; + return this; + } +} diff --git a/public/app/features/variables/shared/testing/builders.ts b/public/app/features/variables/shared/testing/builders.ts new file mode 100644 index 00000000000..92c57747d72 --- /dev/null +++ b/public/app/features/variables/shared/testing/builders.ts @@ -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); diff --git a/public/app/features/variables/shared/testing/datasourceVariableBuilder.ts b/public/app/features/variables/shared/testing/datasourceVariableBuilder.ts new file mode 100644 index 00000000000..8922450835f --- /dev/null +++ b/public/app/features/variables/shared/testing/datasourceVariableBuilder.ts @@ -0,0 +1,14 @@ +import { MultiVariableBuilder } from './multiVariableBuilder'; +import { DataSourceVariableModel, VariableRefresh } from 'app/features/templating/variable'; + +export class DatasourceVariableBuilder extends MultiVariableBuilder { + withRefresh(refresh: VariableRefresh) { + this.variable.refresh = refresh; + return this; + } + + withRegEx(regex: any) { + this.variable.regex = regex; + return this; + } +} diff --git a/public/app/features/variables/shared/testing/intervalVariableBuilder.ts b/public/app/features/variables/shared/testing/intervalVariableBuilder.ts new file mode 100644 index 00000000000..92a4a448f95 --- /dev/null +++ b/public/app/features/variables/shared/testing/intervalVariableBuilder.ts @@ -0,0 +1,24 @@ +import { OptionsVariableBuilder } from './optionsVariableBuilder'; +import { IntervalVariableModel, VariableRefresh } from 'app/features/templating/variable'; + +export class IntervalVariableBuilder extends OptionsVariableBuilder { + 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; + } +} diff --git a/public/app/features/variables/shared/testing/multiVariableBuilder.ts b/public/app/features/variables/shared/testing/multiVariableBuilder.ts new file mode 100644 index 00000000000..a93e0e6e848 --- /dev/null +++ b/public/app/features/variables/shared/testing/multiVariableBuilder.ts @@ -0,0 +1,9 @@ +import { VariableWithMultiSupport } from 'app/features/templating/variable'; +import { OptionsVariableBuilder } from './optionsVariableBuilder'; + +export class MultiVariableBuilder extends OptionsVariableBuilder { + withMulti(multi = true) { + this.variable.multi = multi; + return this; + } +} diff --git a/public/app/features/variables/shared/testing/optionsVariableBuilder.ts b/public/app/features/variables/shared/testing/optionsVariableBuilder.ts new file mode 100644 index 00000000000..3d7d163caa2 --- /dev/null +++ b/public/app/features/variables/shared/testing/optionsVariableBuilder.ts @@ -0,0 +1,35 @@ +import { VariableWithOptions, VariableOption } from 'app/features/templating/variable'; +import { VariableBuilder } from './variableBuilder'; + +export class OptionsVariableBuilder extends VariableBuilder { + 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; + } +} diff --git a/public/app/features/variables/shared/testing/variableBuilder.ts b/public/app/features/variables/shared/testing/variableBuilder.ts new file mode 100644 index 00000000000..1e69da0a76c --- /dev/null +++ b/public/app/features/variables/shared/testing/variableBuilder.ts @@ -0,0 +1,25 @@ +import cloneDeep from 'lodash/cloneDeep'; +import { VariableModel } from 'app/features/templating/variable'; + +export class VariableBuilder { + 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; + } +} diff --git a/public/app/features/variables/state/actions.test.ts b/public/app/features/variables/state/actions.test.ts index b09a0ca4d65..5a038a10bd1 100644 --- a/public/app/features/variables/state/actions.test.ts +++ b/public/app/features/variables/state/actions.test.ts @@ -1,7 +1,7 @@ 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 }; diff --git a/public/app/features/variables/state/helpers.ts b/public/app/features/variables/state/helpers.ts index f63e3a982f0..64c85a8e042 100644 --- a/public/app/features/variables/state/helpers.ts +++ b/public/app/features/variables/state/helpers.ts @@ -1,13 +1,13 @@ 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 = ( 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({ diff --git a/public/app/features/variables/state/processVariable.test.ts b/public/app/features/variables/state/processVariable.test.ts index 829a545b4b1..ee2bfe1509b 100644 --- a/public/app/features/variables/state/processVariable.test.ts +++ b/public/app/features/variables/state/processVariable.test.ts @@ -1,6 +1,6 @@ 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]; diff --git a/public/app/features/variables/state/reducers.ts b/public/app/features/variables/state/reducers.ts index 26c439ee8ae..3246e203746 100644 --- a/public/app/features/variables/state/reducers.ts +++ b/public/app/features/variables/state/reducers.ts @@ -12,8 +12,8 @@ export interface TemplatingState { export default { templating: combineReducers({ - optionsPicker: optionsPickerReducer, editor: variableEditorReducer, variables: variablesReducer, + optionsPicker: optionsPickerReducer, }), }; diff --git a/public/app/features/variables/state/selectors.ts b/public/app/features/variables/state/selectors.ts index 2b5e0374d6c..7fb77f743c0 100644 --- a/public/app/features/variables/state/selectors.ts +++ b/public/app/features/variables/state/selectors.ts @@ -16,17 +16,26 @@ export const getVariable = ( 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; +}; diff --git a/public/app/plugins/panel/table/module.ts b/public/app/plugins/panel/table/module.ts index 507332e837f..e3748b24afa 100644 --- a/public/app/plugins/panel/table/module.ts +++ b/public/app/plugins/panel/table/module.ts @@ -1,7 +1,7 @@ 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); diff --git a/public/test/core/redux/reduxTester.ts b/public/test/core/redux/reduxTester.ts index fccb2d0256c..3184d283525 100644 --- a/public/test/core/redux/reduxTester.ts +++ b/public/test/core/redux/reduxTester.ts @@ -69,8 +69,11 @@ export const reduxTester = (args?: ReduxTesterArguments): 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 = (args?: ReduxTesterArguments): 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; };