Repository: getredash/redash
Pull request: Dashboard Parameters
1. Split `Add Widget` button (and modal) into two buttons (and modals): `Add Widget` and `Add Textbox`. 
2. Implement new logic of mapping query parameters to dashboard parameters (as described in issue).

Screenshots:

![image](https://user-images.githubusercontent.com/12139186/44398630-44cab400-a54d-11e8-9ec4-c891eca38012.png)
![image](https://user-images.githubusercontent.com/12139186/44398639-4dbb8580-a54d-11e8-8a01-6b304b6efcf6.png)
![image](https://user-images.githubusercontent.com/12139186/44398656-58761a80-a54d-11e8-8eeb-ead0699f45f0.png)
![image](https://user-images.githubusercontent.com/12139186/45775906-dff09f80-bc59-11e8-95ce-45283d77d60e.png)
![image](https://user-images.githubusercontent.com/12139186/45775945-feef3180-bc59-11e8-8346-c092ead63f70.png)
![image](https://user-images.githubusercontent.com/12139186/45775953-06aed600-bc5a-11e8-904e-776c1fbcbb6e.png)

Closes #1750, closes #2641.

Problem statement:
Parameters with the same name cause a conflict in dashboards
### Issue Summary

If you have a dashboard that references two or more queries that each have a non-global parameter with the same name one parameter's value will overwrite the others'.

### Steps to Reproduce

1. Create Query1 and Query2 each with a non-global template named "a_parameter".
2. Set "a_parameter" to 1 in Query1 and 2 in Query2.
3. Add both queries as tables to the same dashboard.
One query's "a_parameter" value will overwrite the other query's.

Any other info e.g. Why do you consider this to be a bug? What did you expect to happen instead?
I consider this to be a bug. I expect that, if a parameter is set to non-local, it will not interact with any other parameter in a given dashboard.


Dashboard Parameters
Today to have a parameter at the dashboard level, we use the `global` flag for parameters.

New suggested flow:

* Deprecate the `global` flag.
* When adding a new widget to the dashboard, if the query has parameters we will show them to the user and let him pick what to do with them:
	1. Add the parameter to the dashboard (default option), creating a dashboard parameter.
	2. Map it to existing dashboard parameter (default option, if parameter with the same name exists).
	3. Give the parameter a static value.
	4. (Not sure we need this:) Keep it a standalone parameter at the widget level.

Things to think about:
* How to show if a widget is affected by a parameter? Maybe color code them, and show this color on the widget?

Hints from issue discussion:
By template you mean parameter?
Yes-my bad-parameter. Updated issue.
This is related: add ability to add viz to dashboard from query page https://github.com/getredash/redash/pull/2656
This is also a great opportunity to improve the Add Widget modal and make it nicer.
<img width="802" alt="screenshot 2018-07-13 17 44 43" src="https://user-images.githubusercontent.com/2378022/42700574-734abd2c-86c4-11e8-838d-234de9a41f26.png">

I've created this wireframe for the new modal to open the discussion.

- I'm wondering if we should move the "Text box" option to somewhere out of this modal. My assumption is that text boxes are not as widely used as query visualizations. (first I was considering using an option to select which one you want to add on the [top of this modal](https://www.dropbox.com/s/8pz9rfuzadl0cl4/Screenshot%202018-07-13%2017.46.34.png?dl=0))
- The parameters section is the challenge here. I think we need to improve the separation of the parameters and options for the different methods of adding them.
👍 

How about we change the visualization selection from dropdown to multiple "cards" - one for each visualization, and show the parameters section after selecting the visualization?

Maybe even convert it to 3 steps:

1. Pick query (single screen, with recent queries and ability to search).
2. Pick visualization - at this point the query is selected and shown by name.
3. Setup parameters (if needed)

?
1. Add text box will get it's own modal and we're adding a separate button in edit dashboard mode: 
![add-textbox](https://user-images.githubusercontent.com/2378022/42908898-7a698468-8ae2-11e8-92fc-664b7f80a2b9.png)

2. Here's how it looks if we break it down to 3 steps. I'm not 100% convinced we need this, it feels the process gets a bit complicated. I created a quick prototype to demonstrate how this would work: https://marvelapp.com/18514ie6

WDYT?
>Add text box will get it's own modal and we're adding a separate button in edit dashboard mode:

👍 

> Here's how it looks if we break it down to 3 steps. I'm not 100% convinced we need this, it feels the process gets a bit complicated. I created a quick prototype to demonstrate how this would work: https://marvelapp.com/18514ie6

It's only complex because you made it complex 😅 Why the extra "Next" button? If it submits the action when you pick the query/visualization, it no longer feels that complex, right?
I think I understood your 3 steps idea too literally :)

https://marvelapp.com/18514ie6

I think you meant this, which works fine and much simpler indeed.


@arikfr @kocsmy Can I start to implement it? BTW - what is the dropdown next to each parameter on third step (`Add to dashboard` / `Map to existing parameter`)? Maybe, it could be a sort of checkbox - `Add to dashboard`?
@kravets-levko the reason it's a dropdown is because you have multiple options (Add to dashboard, Map to existing param, use static value).

I think you can start, but @kocsmy can confirm if he wants to do another iteration on the UX/design. Also, @kocsmy I think you should update the issue description with the latest mockup/design.
@kravets-levko For the record, I've merged #2767 which has some overlap with this.

Commit message: Use Ant's Select component instead on <select> tags

Patch to review:
diff --git a/client/.eslintrc.js b/client/.eslintrc.js
index 10fae2b4c6..17552fcd15 100644
--- a/client/.eslintrc.js
+++ b/client/.eslintrc.js
@@ -26,6 +26,7 @@ module.exports = {
     "no-lonely-if": "off",
     "consistent-return": "off",
     "no-control-regex": "off",
+    "no-script-url": "off", // some <a> tags should have href="javascript:void(0)"
     "react/jsx-filename-extension": "off",
     "react/jsx-uses-react": "error",
     "react/jsx-uses-vars": "error",
@@ -33,6 +34,9 @@ module.exports = {
     "react/forbid-prop-types": "warn",
     "react/prop-types": "warn",
     "jsx-a11y/anchor-is-valid": "off",
+    "jsx-a11y/click-events-have-key-events": "off",
+    "jsx-a11y/label-has-for": "off",
+    "jsx-a11y/no-static-element-interactions": "off",
     "max-len": ['error', 120, 2, {
       ignoreUrls: true,
       ignoreComments: false,
diff --git a/client/app/assets/less/redash/redash-newstyle.less b/client/app/assets/less/redash/redash-newstyle.less
index 2f18c05ad4..4eaffb6f57 100644
--- a/client/app/assets/less/redash/redash-newstyle.less
+++ b/client/app/assets/less/redash/redash-newstyle.less
@@ -35,6 +35,10 @@ body {
   }
 }
 
+.word-wrap-break {
+  word-wrap: break-word;
+}
+
 .clearboth {
   clear: both;
 }
diff --git a/client/app/components/DateInput.jsx b/client/app/components/DateInput.jsx
index e72dc4112e..695a2a1997 100644
--- a/client/app/components/DateInput.jsx
+++ b/client/app/components/DateInput.jsx
@@ -4,11 +4,12 @@ import PropTypes from 'prop-types';
 import { react2angular } from 'react2angular';
 import DatePicker from 'antd/lib/date-picker';
 
-function DateInput({
+export function DateInput({
   value,
   onSelect,
   // eslint-disable-next-line react/prop-types
   clientConfig,
+  className,
 }) {
   const format = clientConfig.dateFormat || 'YYYY-MM-DD';
   const additionalAttributes = {};
@@ -17,6 +18,7 @@ function DateInput({
   }
   return (
     <DatePicker
+      className={className}
       {...additionalAttributes}
       format={format}
       placeholder="Select Date"
@@ -34,11 +36,13 @@ DateInput.propTypes = {
     }
   },
   onSelect: PropTypes.func,
+  className: PropTypes.string,
 };
 
 DateInput.defaultProps = {
   value: null,
   onSelect: () => {},
+  className: '',
 };
 
 export default function init(ngModule) {
diff --git a/client/app/components/DateRangeInput.jsx b/client/app/components/DateRangeInput.jsx
index 0d864216ad..895fb1c77d 100644
--- a/client/app/components/DateRangeInput.jsx
+++ b/client/app/components/DateRangeInput.jsx
@@ -5,11 +5,12 @@ import PropTypes from 'prop-types';
 import { react2angular } from 'react2angular';
 import { RangePicker } from 'antd/lib/date-picker';
 
-function DateRangeInput({
+export function DateRangeInput({
   value,
   onSelect,
   // eslint-disable-next-line react/prop-types
   clientConfig,
+  className,
 }) {
   const format = clientConfig.dateFormat || 'YYYY-MM-DD';
   const additionalAttributes = {};
@@ -18,6 +19,7 @@ function DateRangeInput({
   }
   return (
     <RangePicker
+      className={className}
       {...additionalAttributes}
       format={format}
       onChange={onSelect}
@@ -39,11 +41,13 @@ DateRangeInput.propTypes = {
     }
   },
   onSelect: PropTypes.func,
+  className: PropTypes.string,
 };
 
 DateRangeInput.defaultProps = {
   value: null,
   onSelect: () => {},
+  className: '',
 };
 
 export default function init(ngModule) {
diff --git a/client/app/components/DateTimeInput.jsx b/client/app/components/DateTimeInput.jsx
index 739ec17f8d..d95fd60884 100644
--- a/client/app/components/DateTimeInput.jsx
+++ b/client/app/components/DateTimeInput.jsx
@@ -4,12 +4,13 @@ import PropTypes from 'prop-types';
 import { react2angular } from 'react2angular';
 import DatePicker from 'antd/lib/date-picker';
 
-function DateTimeInput({
+export function DateTimeInput({
   value,
   withSeconds,
   onSelect,
   // eslint-disable-next-line react/prop-types
   clientConfig,
+  className,
 }) {
   const format = (clientConfig.dateFormat || 'YYYY-MM-DD') +
     (withSeconds ? ' HH:mm:ss' : ' HH:mm');
@@ -19,6 +20,7 @@ function DateTimeInput({
   }
   return (
     <DatePicker
+      className={className}
       showTime
       {...additionalAttributes}
       format={format}
@@ -38,12 +40,14 @@ DateTimeInput.propTypes = {
   },
   withSeconds: PropTypes.bool,
   onSelect: PropTypes.func,
+  className: PropTypes.string,
 };
 
 DateTimeInput.defaultProps = {
   value: null,
   withSeconds: false,
   onSelect: () => {},
+  className: '',
 };
 
 export default function init(ngModule) {
diff --git a/client/app/components/DateTimeRangeInput.jsx b/client/app/components/DateTimeRangeInput.jsx
index 5cab36c0f6..9715d4cc5f 100644
--- a/client/app/components/DateTimeRangeInput.jsx
+++ b/client/app/components/DateTimeRangeInput.jsx
@@ -5,12 +5,13 @@ import PropTypes from 'prop-types';
 import { react2angular } from 'react2angular';
 import { RangePicker } from 'antd/lib/date-picker';
 
-function DateTimeRangeInput({
+export function DateTimeRangeInput({
   value,
   withSeconds,
   onSelect,
   // eslint-disable-next-line react/prop-types
   clientConfig,
+  className,
 }) {
   const format = (clientConfig.dateFormat || 'YYYY-MM-DD') +
     (withSeconds ? ' HH:mm:ss' : ' HH:mm');
@@ -20,6 +21,7 @@ function DateTimeRangeInput({
   }
   return (
     <RangePicker
+      className={className}
       showTime
       {...additionalAttributes}
       format={format}
@@ -43,12 +45,14 @@ DateTimeRangeInput.propTypes = {
   },
   withSeconds: PropTypes.bool,
   onSelect: PropTypes.func,
+  className: PropTypes.string,
 };
 
 DateTimeRangeInput.defaultProps = {
   value: null,
   withSeconds: false,
   onSelect: () => {},
+  className: '',
 };
 
 export default function init(ngModule) {
diff --git a/client/app/components/ParameterMappingInput.jsx b/client/app/components/ParameterMappingInput.jsx
new file mode 100644
index 0000000000..7521084274
--- /dev/null
+++ b/client/app/components/ParameterMappingInput.jsx
@@ -0,0 +1,267 @@
+/* eslint react/no-multi-comp: 0 */
+
+import { extend, map, includes, findIndex, find, fromPairs } from 'lodash';
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Select } from 'antd';
+import { ParameterValueInput } from '@/components/ParameterValueInput';
+import { ParameterMappingType } from '@/services/widget';
+
+const Option = Select.Option;
+
+export const MappingType = {
+  DashboardAddNew: 'dashboard-add-new',
+  DashboardMapToExisting: 'dashboard-map-to-existing',
+  WidgetLevel: 'widget-level',
+  StaticValue: 'static-value',
+};
+
+export function parameterMappingsToEditableMappings(mappings, parameters, existingParameterNames = []) {
+  return map(mappings, (mapping) => {
+    const result = extend({}, mapping);
+    const alreadyExists = includes(existingParameterNames, mapping.mapTo);
+    result.param = find(parameters, p => p.name === mapping.name);
+    switch (mapping.type) {
+      case ParameterMappingType.DashboardLevel:
+        result.type = alreadyExists ? MappingType.DashboardMapToExisting : MappingType.DashboardAddNew;
+        result.value = null;
+        break;
+      case ParameterMappingType.StaticValue:
+        result.type = MappingType.StaticValue;
+        result.param = result.param.clone();
+        result.param.setValue(result.value);
+        break;
+      case ParameterMappingType.WidgetLevel:
+        result.type = MappingType.WidgetLevel;
+        result.value = null;
+        break;
+      // no default
+    }
+    return result;
+  });
+}
+
+export function editableMappingsToParameterMappings(mappings) {
+  return fromPairs(map( // convert to map
+    mappings,
+    (mapping) => {
+      const result = extend({}, mapping);
+      switch (mapping.type) {
+        case MappingType.DashboardAddNew:
+          result.type = ParameterMappingType.DashboardLevel;
+          result.value = null;
+          break;
+        case MappingType.DashboardMapToExisting:
+          result.type = ParameterMappingType.DashboardLevel;
+          result.value = null;
+          break;
+        case MappingType.StaticValue:
+          result.type = ParameterMappingType.StaticValue;
+          result.param = mapping.param.clone();
+          result.param.setValue(result.value);
+          result.value = result.param.value;
+          break;
+        case MappingType.WidgetLevel:
+          result.type = ParameterMappingType.WidgetLevel;
+          result.value = null;
+          break;
+        // no default
+      }
+      delete result.param;
+      return [result.name, result];
+    },
+  ));
+}
+
+export class ParameterMappingInput extends React.Component {
+  static propTypes = {
+    mapping: PropTypes.object, // eslint-disable-line react/forbid-prop-types
+    existingParamNames: PropTypes.arrayOf(PropTypes.string),
+    onChange: PropTypes.func,
+    clientConfig: PropTypes.any, // eslint-disable-line react/forbid-prop-types
+    Query: PropTypes.any, // eslint-disable-line react/forbid-prop-types
+  };
+
+  static defaultProps = {
+    mapping: {},
+    existingParamNames: [],
+    onChange: () => {},
+    clientConfig: null,
+    Query: null,
+  };
+
+  updateParamMapping(mapping, updates) {
+    this.props.onChange(extend({}, mapping, updates));
+  }
+
+  renderMappingTypeSelector() {
+    const { mapping, existingParamNames } = this.props;
+    return (
+      <div>
+        <Select
+          className="w-100"
+          value={mapping.type}
+          onChange={type => this.updateParamMapping(mapping, { type })}
+          dropdownClassName="ant-dropdown-in-bootstrap-modal"
+        >
+          <Option value={MappingType.DashboardAddNew}>Add the parameter to the dashboard</Option>
+          {
+            (existingParamNames.length > 0) &&
+            <Option value={MappingType.DashboardMapToExisting}>Map to existing parameter</Option>
+          }
+          <Option value={MappingType.StaticValue}>Use static value for the parameter</Option>
+          <Option value={MappingType.WidgetLevel}>Keep the parameter at the widget level</Option>
+        </Select>
+      </div>
+    );
+  }
+
+  renderDashboardAddNew() {
+    const { mapping, existingParamNames } = this.props;
+    const alreadyExists = includes(existingParamNames, mapping.mapTo);
+    return (
+      <div className={'m-t-10' + (alreadyExists ? ' has-error' : '')}>
+        <input
+          type="text"
+          className="form-control"
+          value={mapping.mapTo}
+          onChange={event => this.updateParamMapping(mapping, { mapTo: event.target.value })}
+        />
+        { alreadyExists &&
+        <div className="help-block">
+          Dashboard parameter with this name already exists
+        </div>
+        }
+      </div>
+    );
+  }
+
+  renderDashboardMapToExisting() {
+    const { mapping, existingParamNames } = this.props;
+    return (
+      <div className="m-t-10">
+        <Select
+          className="w-100"
+          value={mapping.mapTo}
+          onChange={mapTo => this.updateParamMapping(mapping, { mapTo })}
+          disabled={existingParamNames.length === 0}
+          dropdownClassName="ant-dropdown-in-bootstrap-modal"
+        >
+          {map(existingParamNames, name => (
+            <Option value={name} key={name}>{ name }</Option>
+          ))}
+        </Select>
+      </div>
+    );
+  }
+
+  renderStaticValue() {
+    const { mapping } = this.props;
+    return (
+      <div className="m-t-10">
+        <ParameterValueInput
+          className="w-100"
+          type={mapping.param.type}
+          value={mapping.value}
+          enumOptions={mapping.param.enumOptions}
+          queryId={mapping.param.queryId}
+          onSelect={value => this.updateParamMapping(mapping, { value })}
+          clientConfig={this.props.clientConfig}
+          Query={this.props.Query}
+        />
+      </div>
+    );
+  }
+
+  renderInputBlock() {
+    const { mapping } = this.props;
+    switch (mapping.type) {
+      case MappingType.DashboardAddNew: return this.renderDashboardAddNew();
+      case MappingType.DashboardMapToExisting: return this.renderDashboardMapToExisting();
+      case MappingType.StaticValue: return this.renderStaticValue();
+      // no default
+    }
+  }
+
+  renderTitleInput() {
+    const { mapping } = this.props;
+    return (
+      <div className="m-t-10">
+        <input
+          type="text"
+          className="form-control"
+          value={mapping.title}
+          onChange={event => this.updateParamMapping(mapping, { title: event.target.value })}
+          placeholder="Change parameter title (leave empty to use existing"
+        />
+      </div>
+    );
+  }
+
+  render() {
+    const { mapping } = this.props;
+    return (
+      <div key={mapping.name} className="row">
+        <div className="col-xs-5">
+          <div className="form-control-static">{'{{ ' + mapping.name + ' }}'}</div>
+        </div>
+        <div className="col-xs-7">
+          {this.renderMappingTypeSelector()}
+          {this.renderInputBlock()}
+          {this.renderTitleInput()}
+        </div>
+      </div>
+    );
+  }
+}
+
+export class ParameterMappingListInput extends React.Component {
+  static propTypes = {
+    mappings: PropTypes.arrayOf(PropTypes.object),
+    existingParamNames: PropTypes.arrayOf(PropTypes.string),
+    onChange: PropTypes.func,
+    clientConfig: PropTypes.any, // eslint-disable-line react/forbid-prop-types
+    Query: PropTypes.any, // eslint-disable-line react/forbid-prop-types
+  };
+
+  static defaultProps = {
+    mappings: [],
+    existingParamNames: [],
+    onChange: () => {},
+    clientConfig: null,
+    Query: null,
+  };
+
+  updateParamMapping(oldMapping, newMapping) {
+    const mappings = [...this.props.mappings];
+    const index = findIndex(mappings, oldMapping);
+    if (index >= 0) {
+      // This should be the only possible case, but need to handle `else` too
+      mappings[index] = newMapping;
+    } else {
+      mappings.push(newMapping);
+    }
+    this.props.onChange(mappings);
+  }
+
+  render() {
+    const clientConfig = this.props.clientConfig; // eslint-disable-line react/prop-types
+    const Query = this.props.Query; // eslint-disable-line react/prop-types
+
+    return (
+      <div>
+        {this.props.mappings.map((mapping, index) => (
+          <div key={mapping.name} className={(index === 0 ? '' : ' m-t-15')}>
+            <ParameterMappingInput
+              mapping={mapping}
+              existingParamNames={this.props.existingParamNames}
+              onChange={newMapping => this.updateParamMapping(mapping, newMapping)}
+              clientConfig={clientConfig}
+              Query={Query}
+            />
+          </div>
+        ))}
+      </div>
+    );
+  }
+}
diff --git a/client/app/components/ParameterValueInput.jsx b/client/app/components/ParameterValueInput.jsx
new file mode 100644
index 0000000000..126e0d7557
--- /dev/null
+++ b/client/app/components/ParameterValueInput.jsx
@@ -0,0 +1,221 @@
+import { isNull, isUndefined } from 'lodash';
+import React from 'react';
+import PropTypes from 'prop-types';
+import { react2angular } from 'react2angular';
+import { Select } from 'antd';
+import { DateInput } from './DateInput';
+import { DateRangeInput } from './DateRangeInput';
+import { DateTimeInput } from './DateTimeInput';
+import { DateTimeRangeInput } from './DateTimeRangeInput';
+import { QueryBasedParameterInput } from './QueryBasedParameterInput';
+
+const Option = Select.Option;
+
+export class ParameterValueInput extends React.Component {
+  static propTypes = {
+    type: PropTypes.string,
+    value: PropTypes.any, // eslint-disable-line react/forbid-prop-types
+    enumOptions: PropTypes.string,
+    queryId: PropTypes.number,
+    onSelect: PropTypes.func,
+    className: PropTypes.string,
+  };
+
+  static defaultProps = {
+    type: 'text',
+    value: null,
+    enumOptions: '',
+    queryId: null,
+    onSelect: () => {},
+    className: '',
+  };
+
+  renderDateTimeWithSecondsInput() {
+    const {
+      value,
+      onSelect,
+      clientConfig, // eslint-disable-line react/prop-types
+    } = this.props;
+    return (
+      <DateTimeInput
+        className={this.props.className}
+        value={value}
+        onSelect={onSelect}
+        withSeconds
+        clientConfig={clientConfig}
+      />
+    );
+  }
+
+  renderDateTimeInput() {
+    const {
+      value,
+      onSelect,
+      clientConfig, // eslint-disable-line react/prop-types
+    } = this.props;
+    return (
+      <DateTimeInput
+        className={this.props.className}
+        value={value}
+        onSelect={onSelect}
+        clientConfig={clientConfig}
+      />
+    );
+  }
+
+  renderDateInput() {
+    const {
+      value,
+      onSelect,
+      clientConfig, // eslint-disable-line react/prop-types
+    } = this.props;
+    return (
+      <DateInput
+        className={this.props.className}
+        value={value}
+        onSelect={onSelect}
+        clientConfig={clientConfig}
+      />
+    );
+  }
+
+  renderDateTimeRangeWithSecondsInput() {
+    const {
+      value,
+      onSelect,
+      clientConfig, // eslint-disable-line react/prop-types
+    } = this.props;
+    return (
+      <DateTimeRangeInput
+        className={this.props.className}
+        value={value}
+        onSelect={onSelect}
+        withSeconds
+        clientConfig={clientConfig}
+      />
+    );
+  }
+
+  renderDateTimeRangeInput() {
+    const {
+      value,
+      onSelect,
+      clientConfig, // eslint-disable-line react/prop-types
+    } = this.props;
+    return (
+      <DateTimeRangeInput
+        className={this.props.className}
+        value={value}
+        onSelect={onSelect}
+        clientConfig={clientConfig}
+      />
+    );
+  }
+
+  renderDateRangeInput() {
+    const {
+      value,
+      onSelect,
+      clientConfig, // eslint-disable-line react/prop-types
+    } = this.props;
+    return (
+      <DateRangeInput
+        className={this.props.className}
+        value={value}
+        onSelect={onSelect}
+        clientConfig={clientConfig}
+      />
+    );
+  }
+
+  renderEnumInput() {
+    const { value, onSelect, enumOptions } = this.props;
+    const enumOptionsArray = enumOptions.split('\n').filter(v => v !== '');
+    return (
+      <Select
+        className={this.props.className}
+        disabled={enumOptionsArray.length === 0}
+        defaultValue={value}
+        onChange={onSelect}
+        dropdownMatchSelectWidth={false}
+        dropdownClassName="ant-dropdown-in-bootstrap-modal"
+      >
+        {enumOptionsArray.map(option => (<Option key={option} value={option}>{ option }</Option>))}
+      </Select>
+    );
+  }
+
+  renderQueryBasedInput() {
+    const {
+      value,
+      onSelect,
+      queryId,
+      Query, // eslint-disable-line react/prop-types
+    } = this.props;
+    return (
+      <QueryBasedParameterInput
+        className={this.props.className}
+        value={value}
+        queryId={queryId}
+        onSelect={onSelect}
+        Query={Query}
+      />
+    );
+  }
+
+  renderTextInput() {
+    const { value, onSelect, type } = this.props;
+    return (
+      <input
+        type={type}
+        className={'form-control ' + this.props.className}
+        value={isNull(value) || isUndefined(value) ? '' : value}
+        onChange={event => onSelect(event.target.value)}
+      />
+    );
+  }
+
+  render() {
+    const { type } = this.props;
+    switch (type) {
+      case 'datetime-with-seconds': return this.renderDateTimeWithSecondsInput();
+      case 'datetime-local': return this.renderDateTimeInput();
+      case 'date': return this.renderDateInput();
+      case 'datetime-range-with-seconds': return this.renderDateTimeRangeWithSecondsInput();
+      case 'datetime-range': return this.renderDateTimeRangeInput();
+      case 'date-range': return this.renderDateRangeInput();
+      case 'enum': return this.renderEnumInput();
+      case 'query': return this.renderQueryBasedInput();
+      default: return this.renderTextInput();
+    }
+  }
+}
+
+export default function init(ngModule) {
+  ngModule.component('parameterValueInput', {
+    template: `
+      <parameter-value-input-impl 
+        type="$ctrl.param.type"       
+        value="$ctrl.param.normalizedValue"       
+        enum-options="$ctrl.param.enumOptions"       
+        query-id="$ctrl.param.queryId"
+        on-select="$ctrl.setValue"       
+      ></parameter-value-input-impl>
+    `,
+    bindings: {
+      param: '<',
+    },
+    controller($scope) {
+      this.setValue = (value) => {
+        this.param.setValue(value);
+        $scope.$applyAsync();
+      };
+    },
+  });
+  ngModule.component(
+    'parameterValueInputImpl',
+    react2angular(ParameterValueInput, null, ['clientConfig', 'Query']),
+  );
+}
+
+init.init = true;
diff --git a/client/app/components/QueryBasedParameterInput.jsx b/client/app/components/QueryBasedParameterInput.jsx
new file mode 100644
index 0000000000..9a2072a746
--- /dev/null
+++ b/client/app/components/QueryBasedParameterInput.jsx
@@ -0,0 +1,121 @@
+import { find, isFunction } from 'lodash';
+import React from 'react';
+import PropTypes from 'prop-types';
+import { react2angular } from 'react2angular';
+import { Select } from 'antd';
+
+const Option = Select.Option;
+
+function optionsFromQueryResult(queryResult) {
+  const columns = queryResult.data.columns;
+  const numColumns = columns.length;
+  let options = [];
+  // If there are multiple columns, check if there is a column
+  // named 'name' and column named 'value'. If name column is present
+  // in results, use name from name column. Similar for value column.
+  // Default: Use first string column for name and value.
+  if (numColumns > 0) {
+    let nameColumn = null;
+    let valueColumn = null;
+    columns.forEach((column) => {
+      const columnName = column.name.toLowerCase();
+      if (columnName === 'name') {
+        nameColumn = column.name;
+      }
+      if (columnName === 'value') {
+        valueColumn = column.name;
+      }
+      // Assign first string column as name and value column.
+      if (nameColumn === null) {
+        nameColumn = column.name;
+      }
+      if (valueColumn === null) {
+        valueColumn = column.name;
+      }
+    });
+    if (nameColumn !== null && valueColumn !== null) {
+      options = queryResult.data.rows.map(row => ({
+        name: row[nameColumn],
+        value: row[valueColumn],
+      }));
+    }
+  }
+  return options;
+}
+
+export class QueryBasedParameterInput extends React.Component {
+  static propTypes = {
+    value: PropTypes.any, // eslint-disable-line react/forbid-prop-types
+    queryId: PropTypes.number,
+    onSelect: PropTypes.func,
+    className: PropTypes.string,
+  };
+
+  static defaultProps = {
+    value: null,
+    queryId: null,
+    onSelect: () => {},
+    className: '',
+  };
+
+  constructor(props) {
+    super(props);
+    this.state = {
+      options: [],
+      loading: false,
+    };
+  }
+
+  componentDidMount() {
+    this._loadOptions(this.props.queryId);
+  }
+
+  // eslint-disable-next-line no-unused-vars
+  componentWillReceiveProps(nextProps) {
+    if (nextProps.queryId !== this.props.queryId) {
+      this._loadOptions(nextProps.queryId, nextProps.value);
+    }
+  }
+
+  _loadOptions(queryId) {
+    if (queryId && (queryId !== this.state.queryId)) {
+      const Query = this.props.Query; // eslint-disable-line react/prop-types
+      this.setState({ loading: true });
+      Query.resultById({ id: queryId }, (result) => {
+        if (this.props.queryId === queryId) {
+          const options = optionsFromQueryResult(result.query_result);
+          this.setState({ options, loading: false });
+
+          const found = find(options, option => option.value === this.props.value) !== undefined;
+          if (!found && isFunction(this.props.onSelect)) {
+            this.props.onSelect(options[0].value);
+          }
+        }
+      });
+    }
+  }
+
+  render() {
+    return (
+      <span>
+        <Select
+          className={this.props.className}
+          disabled={this.state.loading || (this.state.options.length === 0)}
+          loading={this.state.loading}
+          defaultValue={this.props.value}
+          onChange={this.props.onSelect}
+          dropdownMatchSelectWidth={false}
+          dropdownClassName="ant-dropdown-in-bootstrap-modal"
+        >
+          {this.state.options.map(option => (<Option value={option.value} key={option.value}>{option.name}</Option>))}
+        </Select>
+      </span>
+    );
+  }
+}
+
+export default function init(ngModule) {
+  ngModule.component('queryBasedParameterInput', react2angular(QueryBasedParameterInput, null, ['Query']));
+}
+
+init.init = true;
diff --git a/client/app/components/dashboards/AddTextboxDialog.jsx b/client/app/components/dashboards/AddTextboxDialog.jsx
new file mode 100644
index 0000000000..97fead0cc8
--- /dev/null
+++ b/client/app/components/dashboards/AddTextboxDialog.jsx
@@ -0,0 +1,154 @@
+import { markdown } from 'markdown';
+import { debounce } from 'lodash';
+import React from 'react';
+import PropTypes from 'prop-types';
+import { react2angular } from 'react2angular';
+
+class AddTextboxDialog extends React.Component {
+  static propTypes = {
+    dashboard: PropTypes.object, // eslint-disable-line react/forbid-prop-types
+    close: PropTypes.func,
+    dismiss: PropTypes.func,
+  };
+
+  static defaultProps = {
+    dashboard: null,
+    close: () => {},
+    dismiss: () => {},
+  };
+
+  constructor(props) {
+    super(props);
+    this.state = {
+      saveInProgress: false,
+      text: '',
+      preview: '',
+    };
+
+    const updatePreview = debounce(() => {
+      this.setState({
+        preview: markdown.toHTML(this.state.text),
+      });
+    }, 100);
+
+    this.onTextChanged = (event) => {
+      this.setState({ text: event.target.value });
+      updatePreview();
+    };
+  }
+
+  saveWidget() {
+    const Widget = this.props.Widget; // eslint-disable-line react/prop-types
+    const toastr = this.props.toastr; // eslint-disable-line react/prop-types
+    const dashboard = this.props.dashboard;
+
+    this.setState({ saveInProgress: true });
+
+    const widget = new Widget({
+      visualization_id: null,
+      dashboard_id: dashboard.id,
+      options: {
+        isHidden: false,
+        position: {},
+      },
+      visualization: null,
+      text: this.state.text,
+    });
+
+    const position = dashboard.calculateNewWidgetPosition(widget);
+    widget.options.position.col = position.col;
+    widget.options.position.row = position.row;
+
+    widget
+      .save()
+      .then(() => {
+        dashboard.widgets.push(widget);
+        this.props.close();
+      })
+      .catch(() => {
+        toastr.error('Widget can not be added');
+      })
+      .finally(() => {
+        this.setState({ saveInProgress: false });
+      });
+  }
+
+  render() {
+    return (
+      <div>
+        <div className="modal-header">
+          <button
+            type="button"
+            className="close"
+            disabled={this.state.saveInProgress}
+            aria-hidden="true"
+            onClick={this.props.dismiss}
+          >
+            &times;
+          </button>
+          <h4 className="modal-title">Add Textbox</h4>
+        </div>
+        <div className="modal-body">
+          <div className="form-group m-b-0">
+            <textarea
+              className="form-control resize-vertical"
+              style={{ minMeight: '100px' }}
+              rows="5"
+              value={this.state.text}
+              onChange={this.onTextChanged}
+            />
+          </div>
+          <div
+            ng-show="$ctrl.text"
+            className="m-t-15"
+          >
+            <strong>Preview:</strong>
+            <p
+              dangerouslySetInnerHTML={{ __html: this.state.preview }} // eslint-disable-line react/no-danger
+              className="word-wrap-break"
+            />
+          </div>
+        </div>
+
+        <div className="modal-footer">
+          <button
+            type="button"
+            className="btn btn-default"
+            disabled={this.state.saveInProgress}
+            onClick={this.props.dismiss}
+          >
+            Close
+          </button>
+          <button
+            type="button"
+            className="btn btn-primary"
+            disabled={this.state.saveInProgress}
+            onClick={() => this.saveWidget()}
+          >
+            Add to Dashboard
+          </button>
+        </div>
+      </div>
+    );
+  }
+}
+
+export default function init(ngModule) {
+  ngModule.component('addTextboxDialog', {
+    template: `
+      <add-textbox-dialog-impl 
+        dashboard="$ctrl.resolve.dashboard"
+        close="$ctrl.close"
+        dismiss="$ctrl.dismiss"
+      ></add-textbox-dialog-impl>
+    `,
+    bindings: {
+      resolve: '<',
+      close: '&',
+      dismiss: '&',
+    },
+  });
+  ngModule.component('addTextboxDialogImpl', react2angular(AddTextboxDialog, null, ['toastr', 'Widget']));
+}
+
+init.init = true;
diff --git a/client/app/components/dashboards/AddWidgetDialog.jsx b/client/app/components/dashboards/AddWidgetDialog.jsx
new file mode 100644
index 0000000000..c40918d3de
--- /dev/null
+++ b/client/app/components/dashboards/AddWidgetDialog.jsx
@@ -0,0 +1,365 @@
+import { debounce, each, values, map, includes, first } from 'lodash';
+import React from 'react';
+import PropTypes from 'prop-types';
+import { react2angular } from 'react2angular';
+import { Select } from 'antd';
+import highlight from '@/lib/highlight';
+import {
+  MappingType,
+  ParameterMappingListInput,
+  editableMappingsToParameterMappings,
+} from '@/components/ParameterMappingInput';
+
+const { Option, OptGroup } = Select;
+
+class AddWidgetDialog extends React.Component {
+  static propTypes = {
+    dashboard: PropTypes.object, // eslint-disable-line react/forbid-prop-types
+    close: PropTypes.func,
+    dismiss: PropTypes.func,
+  };
+
+  static defaultProps = {
+    dashboard: null,
+    close: () => {},
+    dismiss: () => {},
+  };
+
+  constructor(props) {
+    super(props);
+    this.state = {
+      saveInProgress: false,
+      selectedQuery: null,
+      searchTerm: '',
+      recentQueries: [],
+      searchedQueries: [],
+      selectedVis: null,
+      parameterMappings: [],
+    };
+
+    // Don't show draft (unpublished) queries
+    const Query = this.props.Query; // eslint-disable-line react/prop-types
+    Query.recent().$promise.then((items) => {
+      this.setState({
+        recentQueries: items.filter(item => !item.is_draft),
+      });
+    });
+
+    const searchQueries = debounce(this.searchQueries.bind(this), 200);
+    this.onSearchTermChanged = (event) => {
+      const searchTerm = event.target.value;
+      this.setState({ searchTerm });
+      searchQueries(searchTerm);
+    };
+  }
+
+  selectQuery(queryId) {
+    // Clear previously selected query (if any)
+    this.setState({
+      selectedQuery: null,
+      selectedVis: null,
+      parameterMappings: [],
+    });
+
+    if (queryId) {
+      const Query = this.props.Query; // eslint-disable-line react/prop-types
+      Query.get({ id: queryId }, (query) => {
+        if (query) {
+          const existingParamNames = map(
+            this.props.dashboard.getParametersDefs(),
+            param => param.name,
+          );
+          this.setState({
+            selectedQuery: query,
+            parameterMappings: map(query.getParametersDefs(), param => ({
+              name: param.name,
+              type: includes(existingParamNames, param.name)
+                ? MappingType.DashboardMapToExisting : MappingType.DashboardAddNew,
+              mapTo: param.name,
+              value: param.normalizedValue,
+              title: '',
+              param,
+            })),
+          });
+          if (query.visualizations.length) {
+            this.setState({ selectedVis: query.visualizations[0] });
+          }
+        }
+      });
+    }
+  }
+
+  searchQueries(term) {
+    if (!term || term.length === 0) {
+      this.setState({ searchedQueries: [] });
+      return;
+    }
+
+    const Query = this.props.Query; // eslint-disable-line react/prop-types
+    Query.query({ q: term }, (results) => {
+      // If user will type too quick - it's possible that there will be
+      // several requests running simultaneously. So we need to check
+      // which results are matching current search term and ignore
+      // outdated results.
+      if (this.state.searchTerm === term) {
+        this.setState({ searchedQueries: results.results });
+      }
+    });
+  }
+
+  selectVisualization(query, visualizationId) {
+    each(query.visualizations, (visualization) => {
+      if (visualization.id === visualizationId) {
+        this.setState({ selectedVis: visualization });
+        return false;
+      }
+    });
+  }
+
+  saveWidget() {
+    const Widget = this.props.Widget; // eslint-disable-line react/prop-types
+    const toastr = this.props.toastr; // eslint-disable-line react/prop-types
+    const dashboard = this.props.dashboard;
+
+    this.setState({ saveInProgress: true });
+
+    const widget = new Widget({
+      visualization_id: this.state.selectedVis && this.state.selectedVis.id,
+      dashboard_id: dashboard.id,
+      options: {
+        isHidden: false,
+        position: {},
+        parameterMappings: editableMappingsToParameterMappings(this.state.parameterMappings),
+      },
+      visualization: this.state.selectedVis,
+      text: '',
+    });
+
+    const position = dashboard.calculateNewWidgetPosition(widget);
+    widget.options.position.col = position.col;
+    widget.options.position.row = position.row;
+
+    widget
+      .save()
+      .then(() => {
+        dashboard.widgets.push(widget);
+        this.props.close();
+      })
+      .catch(() => {
+        toastr.error('Widget can not be added');
+      })
+      .finally(() => {
+        this.setState({ saveInProgress: false });
+      });
+  }
+
+  updateParamMappings(parameterMappings) {
+    this.setState({ parameterMappings });
+  }
+
+  renderQueryInput() {
+    return (
+      <div className="form-group">
+        {!this.state.selectedQuery && <input
+          type="text"
+          placeholder="Search a query by name"
+          className="form-control"
+          value={this.state.searchTerm}
+          onChange={this.onSearchTermChanged}
+        />}
+        {
+          this.state.selectedQuery &&
+          <div className="p-relative">
+            <input type="text" className="form-control bg-white" value={this.state.selectedQuery.name} readOnly />
+            <a
+              href="javascript:void(0)"
+              onClick={() => this.selectQuery(null)}
+              className="d-flex align-items-center justify-content-center"
+              style={{
+                position: 'absolute',
+                right: '1px',
+                top: '1px',
+                bottom: '1px',
+                width: '30px',
+                background: '#fff',
+                borderRadius: '3px',
+              }}
+            >
+              <i className="text-muted fa fa-times" />
+            </a>
+          </div>
+        }
+      </div>
+    );
+  }
+
+  renderSearchQueryResults() {
+    return (
+      <div className="scrollbox" style={{ maxHeight: '50vh' }}>
+        {
+          (this.state.searchTerm === '') &&
+          <div>
+            {
+              this.state.recentQueries.length > 0 &&
+              <div className="list-group">
+                {this.state.recentQueries.map(query => (
+                  <a
+                    href="javascript:void(0)"
+                    className="list-group-item"
+                    key={query.id}
+                    onClick={() => this.selectQuery(query.id)}
+                  >
+                    {query.name}
+                  </a>
+                ))}
+              </div>
+            }
+          </div>
+        }
+
+        {
+          (this.state.searchTerm !== '') &&
+          <div>
+            {
+              (this.state.searchedQueries.length === 0) &&
+              <div className="text-muted">No results matching search term.</div>
+            }
+            {
+              (this.state.searchedQueries.length > 0) &&
+              <div className="list-group">
+                {this.state.searchedQueries.map(query => (
+                  <a
+                    href="javascript:void(0)"
+                    className="list-group-item"
+                    key={query.id}
+                    onClick={() => this.selectQuery(query.id)}
+                    // eslint-disable-next-line react/no-danger
+                    dangerouslySetInnerHTML={{ __html: highlight(query.name, this.state.searchTerm) }}
+                  />
+                ))}
+              </div>
+            }
+          </div>
+        }
+      </div>
+    );
+  }
+
+  renderVisualizationInput() {
+    let visualizationGroups = {};
+    if (this.state.selectedQuery) {
+      each(this.state.selectedQuery.visualizations, (vis) => {
+        visualizationGroups[vis.type] = visualizationGroups[vis.type] || [];
+        visualizationGroups[vis.type].push(vis);
+      });
+    }
+    visualizationGroups = values(visualizationGroups);
+    return (
+      <div>
+        <div className="form-group">
+          <label>Choose Visualization</label>
+          <Select
+            className="w-100"
+            defaultValue={first(this.state.selectedQuery.visualizations).id}
+            onChange={visualizationId => this.selectVisualization(this.state.selectedQuery, visualizationId)}
+            dropdownClassName="ant-dropdown-in-bootstrap-modal"
+          >
+            {visualizationGroups.map(visualizations => (
+              <OptGroup label={visualizations[0].type} key={visualizations[0].type}>
+                {visualizations.map(visualization => (
+                  <Option value={visualization.id} key={visualization.id}>{visualization.name}</Option>
+                ))}
+              </OptGroup>
+            ))}
+          </Select>
+        </div>
+      </div>
+    );
+  }
+
+  render() {
+    const clientConfig = this.props.clientConfig; // eslint-disable-line react/prop-types
+    const Query = this.props.Query; // eslint-disable-line react/prop-types
+
+    const existingParamNames = map(
+      this.props.dashboard.getParametersDefs(),
+      param => param.name,
+    );
+
+    return (
+      <div>
+        <div className="modal-header">
+          <button
+            type="button"
+            className="close"
+            disabled={this.state.saveInProgress}
+            aria-hidden="true"
+            onClick={this.props.dismiss}
+          >
+            &times;
+          </button>
+          <h4 className="modal-title">Add Widget</h4>
+        </div>
+        <div className="modal-body">
+          {this.renderQueryInput()}
+          {!this.state.selectedQuery && this.renderSearchQueryResults()}
+          {this.state.selectedQuery && this.renderVisualizationInput()}
+
+          {
+            (this.state.parameterMappings.length > 0) && [
+              <label key="parameters-title">Parameters</label>,
+              <ParameterMappingListInput
+                key="parameters-list"
+                mappings={this.state.parameterMappings}
+                existingParamNames={existingParamNames}
+                onChange={mappings => this.updateParamMappings(mappings)}
+                clientConfig={clientConfig}
+                Query={Query}
+              />,
+            ]
+          }
+        </div>
+
+        <div className="modal-footer">
+          <button
+            type="button"
+            className="btn btn-default"
+            disabled={this.state.saveInProgress}
+            onClick={this.props.dismiss}
+          >
+            Close
+          </button>
+          <button
+            type="button"
+            className="btn btn-primary"
+            disabled={this.state.saveInProgress}
+            onClick={() => this.saveWidget()}
+          >
+            Add to Dashboard
+          </button>
+        </div>
+      </div>
+    );
+  }
+}
+
+export default function init(ngModule) {
+  ngModule.component('addWidgetDialog', {
+    template: `
+      <add-widget-dialog-impl 
+        dashboard="$ctrl.resolve.dashboard"
+        close="$ctrl.close"
+        dismiss="$ctrl.dismiss"
+      ></add-widget-dialog-impl>
+    `,
+    bindings: {
+      resolve: '<',
+      close: '&',
+      dismiss: '&',
+    },
+  });
+  ngModule.component('addWidgetDialogImpl', react2angular(AddWidgetDialog, null, [
+    'toastr', 'Widget', 'Query', 'clientConfig']));
+}
+
+init.init = true;
diff --git a/client/app/components/dashboards/EditParameterMappingsDialog.jsx b/client/app/components/dashboards/EditParameterMappingsDialog.jsx
new file mode 100644
index 0000000000..3b1ba76ac7
--- /dev/null
+++ b/client/app/components/dashboards/EditParameterMappingsDialog.jsx
@@ -0,0 +1,141 @@
+import { map } from 'lodash';
+import React from 'react';
+import PropTypes from 'prop-types';
+import { react2angular } from 'react2angular';
+import {
+  ParameterMappingListInput,
+  parameterMappingsToEditableMappings,
+  editableMappingsToParameterMappings,
+} from '@/components/ParameterMappingInput';
+
+class EditParameterMappingsDialog extends React.Component {
+  static propTypes = {
+    dashboard: PropTypes.object, // eslint-disable-line react/forbid-prop-types
+    widget: PropTypes.object, // eslint-disable-line react/forbid-prop-types
+    close: PropTypes.func,
+    dismiss: PropTypes.func,
+  };
+
+  static defaultProps = {
+    dashboard: null,
+    widget: null,
+    close: () => {},
+    dismiss: () => {},
+  };
+
+  constructor(props) {
+    super(props);
+    this.state = {
+      saveInProgress: false,
+      parameterMappings: parameterMappingsToEditableMappings(
+        props.widget.options.parameterMappings,
+        props.widget.query.getParametersDefs(),
+        map(this.props.dashboard.getParametersDefs(), p => p.name),
+      ),
+    };
+  }
+
+  saveWidget() {
+    const toastr = this.props.toastr; // eslint-disable-line react/prop-types
+    const widget = this.props.widget;
+
+    this.setState({ saveInProgress: true });
+
+    widget.options.parameterMappings = editableMappingsToParameterMappings(this.state.parameterMappings);
+    widget
+      .save()
+      .then(() => {
+        this.props.close();
+      })
+      .catch(() => {
+        toastr.error('Widget cannot be updated');
+      })
+      .finally(() => {
+        this.setState({ saveInProgress: false });
+      });
+  }
+
+  updateParamMappings(parameterMappings) {
+    this.setState({ parameterMappings });
+  }
+
+  render() {
+    const clientConfig = this.props.clientConfig; // eslint-disable-line react/prop-types
+    const Query = this.props.Query; // eslint-disable-line react/prop-types
+
+    const existingParamNames = map(
+      this.props.dashboard.getParametersDefs(),
+      param => param.name,
+    );
+
+    return (
+      <div>
+        <div className="modal-header">
+          <button
+            type="button"
+            className="close"
+            disabled={this.state.saveInProgress}
+            aria-hidden="true"
+            onClick={this.props.dismiss}
+          >
+            &times;
+          </button>
+          <h4 className="modal-title">Parameters</h4>
+        </div>
+        <div className="modal-body">
+          {
+            (this.state.parameterMappings.length > 0) &&
+            <ParameterMappingListInput
+              mappings={this.state.parameterMappings}
+              existingParamNames={existingParamNames}
+              onChange={mappings => this.updateParamMappings(mappings)}
+              clientConfig={clientConfig}
+              Query={Query}
+            />
+          }
+        </div>
+
+        <div className="modal-footer">
+          <button
+            type="button"
+            className="btn btn-default"
+            disabled={this.state.saveInProgress}
+            onClick={this.props.dismiss}
+          >
+            Close
+          </button>
+          <button
+            type="button"
+            className="btn btn-primary"
+            disabled={this.state.saveInProgress}
+            onClick={() => this.saveWidget()}
+          >
+            Save
+          </button>
+        </div>
+      </div>
+    );
+  }
+}
+
+export default function init(ngModule) {
+  ngModule.component('editParameterMappingsDialog', {
+    template: `
+      <edit-parameter-mappings-dialog-impl
+        dashboard="$ctrl.resolve.dashboard"
+        widget="$ctrl.resolve.widget"
+        close="$ctrl.close"
+        dismiss="$ctrl.dismiss"
+      ></edit-parameter-mappings-dialog-impl>
+    `,
+    bindings: {
+      resolve: '<',
+      close: '&',
+      dismiss: '&',
+    },
+  });
+  ngModule.component('editParameterMappingsDialogImpl', react2angular(EditParameterMappingsDialog, null, [
+    'Query', 'clientConfig']));
+}
+
+init.init = true;
diff --git a/client/app/components/dashboards/add-widget-dialog.html b/client/app/components/dashboards/add-widget-dialog.html
deleted file mode 100644
index 90676dfbf1..0000000000
--- a/client/app/components/dashboards/add-widget-dialog.html
+++ /dev/null
@@ -1,70 +0,0 @@
-<div class="modal-header">
-  <button type="button" class="close" ng-disabled="$ctrl.saveInProgress" aria-hidden="true" ng-click="$ctrl.dismiss()">&times;</button>
-  <h4 class="modal-title">Add Widget</h4>
-</div>
-<div class="modal-body">
-  <p class="btn-group">
-    <button type="button" class="btn btn-default" ng-class="{active: $ctrl.isVisualization}" ng-click="$ctrl.setType('visualization')">Visualization</button>
-    <button type="button" class="btn btn-default" ng-class="{active: $ctrl.isTextBox}" ng-click="$ctrl.setType('textbox')">Text Box</button>
-  </p>
-
-  <div ng-show="$ctrl.isTextBox">
-    <div class="form-group m-b-0">
-      <textarea class="form-control resize-vertical" style="min-height: 100px"
-        ng-model="$ctrl.text" ng-model-options="{ debounce: 200 }" rows="5" autofocus></textarea>
-    </div>
-    <div ng-show="$ctrl.text" class="m-t-15">
-      <strong>Preview:</strong>
-      <p ng-bind-html="$ctrl.text | markdown" class="word-wrap-break"></p>
-    </div>
-  </div>
-
-  <div ng-show="$ctrl.isVisualization">
-    <div class="form-group">
-      <input type="text" placeholder="Search a query by name" class="form-control" autofocus
-        ng-if="!$ctrl.selectedQuery" ng-model="$ctrl.searchTerm" ng-change="$ctrl.searchQueries($ctrl.searchTerm)">
-      <div ng-if="$ctrl.selectedQuery" class="p-relative">
-        <input type="text" class="form-control bg-white"
-          ng-value="$ctrl.selectedQuery.name" readonly="readonly">
-        <a href="javascript:void(0)" ng-click="$ctrl.selectQuery(null)"
-          class="d-flex align-items-center justify-content-center"
-          style="position: absolute; right: 1px; top: 1px; bottom: 1px; width: 30px; background: #fff; border-radius: 3px;"
-        ><i class="text-muted fa fa-times"></i></a>
-      </div>
-    </div>
-
-    <div ng-if="!$ctrl.selectedQuery" class="scrollbox" style="max-height: 50vh">
-      <div ng-if="$ctrl.searchTerm == ''">
-        <div class="list-group" ng-if="$ctrl.recentQueries.length > 0">
-          <a class="list-group-item" ng-repeat="query in $ctrl.recentQueries"
-            ng-click="$ctrl.selectQuery(query.id)">{{query.name}}</a>
-        </div>
-      </div>
-
-      <div ng-if="$ctrl.searchTerm != ''">
-        <div ng-if="$ctrl.searchedQueries.length == 0" class="text-muted">
-          No results matching search term.
-        </div>
-        <div class="list-group" ng-if="$ctrl.searchedQueries.length > 0">
-          <a class="list-group-item"
-            ng-repeat="query in $ctrl.searchedQueries" ng-click="$ctrl.selectQuery(query.id)"
-            ng-bind-html="$ctrl.trustAsHtml(query.name | highlight: $ctrl.searchTerm)"
-          ></a>
-        </div>
-      </div>
-    </div>
-
-    <div ng-show="$ctrl.selectedQuery">
-      <div class="form-group">
-        <label>Choose Visualization</label>
-        <select ng-model="$ctrl.selectedVis" class="form-control"
-          ng-options="vis as vis.name group by vis.type for vis in $ctrl.selectedQuery.visualizations"></select>
-      </div>
-    </div>
-  </div>
-</div>
-
-<div class="modal-footer">
-  <button type="button" class="btn btn-default" ng-disabled="$ctrl.saveInProgress" ng-click="$ctrl.dismiss()">Close</button>
-  <button type="button" class="btn btn-primary" ng-disabled="$ctrl.saveInProgress || !($ctrl.selectedVis || $ctrl.isTextBox)" ng-click="$ctrl.saveWidget()">Add to Dashboard</button>
-</div>
diff --git a/client/app/components/dashboards/add-widget-dialog.js b/client/app/components/dashboards/add-widget-dialog.js
deleted file mode 100644
index a0ae6fcafb..0000000000
--- a/client/app/components/dashboards/add-widget-dialog.js
+++ /dev/null
@@ -1,118 +0,0 @@
-import { debounce } from 'lodash';
-import template from './add-widget-dialog.html';
-import './add-widget-dialog.less';
-
-const AddWidgetDialog = {
-  template,
-  bindings: {
-    resolve: '<',
-    close: '&',
-    dismiss: '&',
-  },
-  controller($sce, toastr, Query, Widget) {
-    'ngInject';
-
-    this.dashboard = this.resolve.dashboard;
-    this.saveInProgress = false;
-
-    // Textbox
-    this.text = '';
-
-    // Visualization
-    this.selectedQuery = null;
-    this.searchTerm = '';
-    this.recentQueries = [];
-
-    // Don't show draft (unpublished) queries
-    Query.recent().$promise.then((items) => {
-      this.recentQueries = items.filter(item => !item.is_draft);
-    });
-
-    this.searchedQueries = [];
-    this.selectedVis = null;
-
-    this.trustAsHtml = html => $sce.trustAsHtml(html);
-
-    this.setType = (type) => {
-      this.type = type;
-      this.isVisualization = this.type === 'visualization';
-      this.isTextBox = this.type === 'textbox';
-    };
-    this.setType('visualization');
-
-    this.selectQuery = (queryId) => {
-      // Clear previously selected query (if any)
-      this.selectedQuery = null;
-      this.selectedVis = null;
-
-      if (queryId) {
-        Query.get({ id: queryId }, (query) => {
-          if (query) {
-            this.selectedQuery = query;
-            if (query.visualizations.length) {
-              this.selectedVis = query.visualizations[0];
-            }
-          }
-        });
-      }
-    };
-
-    // `ng-model-options` does not work with `ng-change`, so do debounce here
-    this.searchQueries = debounce((term) => {
-      if (!term || term.length === 0) {
-        this.searchedQueries = [];
-        return;
-      }
-
-      Query.query({ q: term }, (results) => {
-        // If user will type too quick - it's possible that there will be
-        // several requests running simultaneously. So we need to check
-        // which results are matching current search term and ignore
-        // outdated results.
-        if (this.searchTerm === term) {
-          this.searchedQueries = results.results;
-        }
-      });
-    }, 200);
-
-    this.saveWidget = () => {
-      this.saveInProgress = true;
-
-      const selectedVis = this.isVisualization ? this.selectedVis : null;
-
-      const widget = new Widget({
-        visualization_id: selectedVis && selectedVis.id,
-        dashboard_id: this.dashboard.id,
-        options: {
-          isHidden: false,
-          position: {},
-        },
-        visualization: selectedVis,
-        text: this.isTextBox ? this.text : '',
-      });
-
-      const position = this.dashboard.calculateNewWidgetPosition(widget);
-      widget.options.position.col = position.col;
-      widget.options.position.row = position.row;
-
-      widget
-        .save()
-        .then(() => {
-          this.dashboard.widgets.push(widget);
-          this.close();
-        })
-        .catch(() => {
-          toastr.error('Widget can not be added');
-        })
-        .finally(() => {
-          this.saveInProgress = false;
-        });
-    };
-  },
-};
-
-export default function init(ngModule) {
-  ngModule.component('addWidgetDialog', AddWidgetDialog);
-}
-
-init.init = true;
diff --git a/client/app/components/dashboards/add-widget-dialog.less b/client/app/components/dashboards/add-widget-dialog.less
deleted file mode 100644
index d156595123..0000000000
--- a/client/app/components/dashboards/add-widget-dialog.less
+++ /dev/null
@@ -1,3 +0,0 @@
-.word-wrap-break {
-	word-wrap: break-word;
-}
\ No newline at end of file
diff --git a/client/app/components/dashboards/widget.html b/client/app/components/dashboards/widget.html
index 1a6a9dbbed..c6f853c47c 100644
--- a/client/app/components/dashboards/widget.html
+++ b/client/app/components/dashboards/widget.html
@@ -8,16 +8,21 @@
             <a ng-click="$ctrl.deleteWidget()" title="Remove From Dashboard"><i class="zmdi zmdi-close"></i></a>
           </div>
         </div>
-        <div class="dropdown pull-right widget-menu-regular" ng-if="!$ctrl.public" uib-dropdown>
+        <div class="dropdown pull-right widget-menu-regular" ng-if="!$ctrl.public"
+          uib-dropdown dropdown-append-to-body="true"
+        >
           <div class="actions">
             <a data-toggle="dropdown" uib-dropdown-toggle><i class="zmdi zmdi-more-vert"></i></a>
           </div>
 
-          <ul class="dropdown-menu pull-right" uib-dropdown-menu style="z-index:1000000">
+          <ul class="dropdown-menu dropdown-menu-right" uib-dropdown-menu>
             <li ng-class="{'disabled': $ctrl.widget.getQueryResult().isEmpty()}"><a ng-href="{{$ctrl.widget.getQueryResult().getLink($ctrl.widget.getQuery().id, 'csv')}}" download="{{$ctrl.widget.getQueryResult().getName($ctrl.widget.getQuery().name, 'csv')}}" target="_self">Download as CSV File</a></li>
             <li ng-class="{'disabled': $ctrl.widget.getQueryResult().isEmpty()}"><a ng-href="{{$ctrl.widget.getQueryResult().getLink($ctrl.widget.getQuery().id, 'xlsx')}}" download="{{$ctrl.widget.getQueryResult().getName($ctrl.widget.getQuery().name, 'xlsx')}}" target="_self">Download as Excel File</a></li>
+            <li class="divider"></li>
             <li><a ng-href="{{$ctrl.widget.getQuery().getUrl(true, $ctrl.widget.visualization.id)}}" ng-show="$ctrl.canViewQuery">View Query</a></li>
-            <li><a ng-show="$ctrl.dashboard.canEdit()" ng-click="$ctrl.deleteWidget()">Remove From Dashboard</a></li>
+            <li><a ng-show="$ctrl.hasParameters()" ng-click="$ctrl.editParameterMappings()">Edit Parameters</a></li>
+            <li class="divider"></li>
+            <li><a ng-show="$ctrl.dashboard.canEdit()" ng-click="$ctrl.deleteWidget()">Remove from Dashboard</a></li>
           </ul>
         </div>
         <div class="th-title">
diff --git a/client/app/components/dashboards/widget.js b/client/app/components/dashboards/widget.js
index f39e7af8e0..db10909541 100644
--- a/client/app/components/dashboards/widget.js
+++ b/client/app/components/dashboards/widget.js
@@ -1,9 +1,9 @@
+import { filter } from 'lodash';
 import template from './widget.html';
 import editTextBoxTemplate from './edit-text-box.html';
 import widgetDialogTemplate from './widget-dialog.html';
 import './widget.less';
 import './widget-dialog.less';
-import './add-widget-dialog.less';
 
 const WidgetDialog = {
   template: widgetDialogTemplate,
@@ -51,7 +51,7 @@ const EditTextBoxComponent = {
   },
 };
 
-function DashboardWidgetCtrl($location, $uibModal, $window, Events, currentUser) {
+function DashboardWidgetCtrl($location, $uibModal, $window, $rootScope, Events, currentUser, Widget) {
   this.canViewQuery = currentUser.hasPermission('view_query');
 
   this.editTextBox = () => {
@@ -75,12 +75,28 @@ function DashboardWidgetCtrl($location, $uibModal, $window, Events, currentUser)
     });
   };
 
+  this.hasParameters = () => this.widget.query.getParametersDefs().length > 0;
+
+  this.editParameterMappings = () => {
+    $uibModal.open({
+      component: 'editParameterMappingsDialog',
+      resolve: {
+        dashboard: this.dashboard,
+        widget: this.widget,
+      },
+      size: 'lg',
+    }).result.then(() => {
+      this.localParameters = null;
+      $rootScope.$broadcast('dashboard.update-parameters');
+    });
+  };
+
   this.localParametersDefs = () => {
     if (!this.localParameters) {
-      this.localParameters = this.widget
-        .getQuery()
-        .getParametersDefs()
-        .filter(p => !p.global);
+      this.localParameters = filter(
+        this.widget.getParametersDefs(),
+        param => param.mappingType !== Widget.MappingType.StaticValue,
+      );
     }
     return this.localParameters;
   };
diff --git a/client/app/components/parameter-input.html b/client/app/components/parameter-input.html
deleted file mode 100644
index 2e18486aa8..0000000000
--- a/client/app/components/parameter-input.html
+++ /dev/null
@@ -1,25 +0,0 @@
-<span ng-switch="$ctrl.param.type">
-  <date-time-input ng-switch-when="datetime-with-seconds" value="$ctrl.param.normalizedValue"
-    on-select="setParamValue" with-seconds="true"></date-time-input>
-  <date-time-input ng-switch-when="datetime-local" value="$ctrl.param.normalizedValue"
-    on-select="setParamValue"></date-time-input>
-  <date-input ng-switch-when="date" value="$ctrl.param.normalizedValue"
-    on-select="setParamValue"></date-input>
-
-  <date-time-range-input ng-switch-when="datetime-range-with-seconds" value="$ctrl.param.normalizedValue"
-    on-select="setParamValue" with-seconds="true"></date-time-range-input>
-  <date-time-range-input ng-switch-when="datetime-range" value="$ctrl.param.normalizedValue"
-    on-select="setParamValue"></date-time-range-input>
-  <date-range-input ng-switch-when="date-range" value="$ctrl.param.normalizedValue"
-    on-select="setParamValue"></date-range-input>
-
-  <span ng-switch-when="enum">
-    <select ng-model="$ctrl.param.value" class="form-control">
-      <option ng-repeat="option in $ctrl.extractEnumOptions($ctrl.param.enumOptions)" value="{{option}}">{{option}}</option>
-    </select>
-  </span>
-  <span ng-switch-when="query">
-    <query-based-parameter param="$ctrl.param" query-id="$ctrl.param.queryId"></query-based-parameter>
-  </span>
-  <input ng-switch-default type="{{$ctrl.param.type}}" class="form-control" ng-model="$ctrl.param.ngModel">
-</span>
diff --git a/client/app/components/parameter-settings.html b/client/app/components/parameter-settings.html
index 5fe11a4e26..337e0e991d 100644
--- a/client/app/components/parameter-settings.html
+++ b/client/app/components/parameter-settings.html
@@ -38,11 +38,6 @@ <h4 class="modal-title">{{ $ctrl.isNewParameter ? 'Add Parameter' : $ctrl.parame
           <option value="datetime-range-with-seconds">Date and Time Range (with seconds)</option>
         </select>
       </div>
-      <div class="form-group">
-        <label>
-          <input type="checkbox" class="form-inline" ng-model="$ctrl.parameter.global"> Global
-        </label>
-      </div>
       <div class="form-group" ng-if="['date', 'datetime-local', 'datetime-with-seconds'].indexOf($ctrl.parameter.type) >= 0">
         <label>
           <input type="checkbox" class="form-inline" ng-model="$ctrl.parameter.useCurrentDateTime"> Use Today/Now as default value if no other value is set
diff --git a/client/app/components/parameters.html b/client/app/components/parameters.html
index 9aa5a76c87..f401d7abb7 100644
--- a/client/app/components/parameters.html
+++ b/client/app/components/parameters.html
@@ -8,6 +8,6 @@
     <button class="btn btn-default btn-xs" ng-if="editable" ng-click="showParameterSettings(param)">
       <i class="zmdi zmdi-settings"></i>
     </button>
-    <parameter-input param="param"></parameter-input>
+    <parameter-value-input param="param"></parameter-value-input>
   </div>
 </div>
diff --git a/client/app/components/parameters.js b/client/app/components/parameters.js
index 9692c32e28..d0f8b93e8d 100644
--- a/client/app/components/parameters.js
+++ b/client/app/components/parameters.js
@@ -1,8 +1,6 @@
-import { find, includes, words, capitalize, extend } from 'lodash';
+import { includes, words, capitalize, extend } from 'lodash';
 import template from './parameters.html';
-import queryBasedParameterTemplate from './query-based-parameter.html';
 import parameterSettingsTemplate from './parameter-settings.html';
-import parameterInputTemplate from './parameter-input.html';
 
 function humanize(str) {
   return capitalize(words(str).join(' '));
@@ -49,75 +47,6 @@ const ParameterSettingsComponent = {
   },
 };
 
-function optionsFromQueryResult(queryResult) {
-  const columns = queryResult.data.columns;
-  const numColumns = columns.length;
-  let options = [];
-  // If there are multiple columns, check if there is a column
-  // named 'name' and column named 'value'. If name column is present
-  // in results, use name from name column. Similar for value column.
-  // Default: Use first string column for name and value.
-  if (numColumns > 0) {
-    let nameColumn = null;
-    let valueColumn = null;
-    columns.forEach((column) => {
-      const columnName = column.name.toLowerCase();
-      if (columnName === 'name') {
-        nameColumn = column.name;
-      }
-      if (columnName === 'value') {
-        valueColumn = column.name;
-      }
-      // Assign first string column as name and value column.
-      if (nameColumn === null) {
-        nameColumn = column.name;
-      }
-      if (valueColumn === null) {
-        valueColumn = column.name;
-      }
-    });
-    if (nameColumn !== null && valueColumn !== null) {
-      options = queryResult.data.rows.map((row) => {
-        const queryResultOption = {
-          name: row[nameColumn],
-          value: row[valueColumn],
-        };
-        return queryResultOption;
-      });
-    }
-  }
-  return options;
-}
-
-function updateCurrentValue(param, options) {
-  const found = find(options, option => option.value === param.value) !== undefined;
-
-  if (!found) {
-    param.value = options[0].value;
-  }
-}
-
-const QueryBasedParameterComponent = {
-  template: queryBasedParameterTemplate,
-  bindings: {
-    param: '<',
-    queryId: '<',
-  },
-  controller(Query) {
-    'ngInject';
-
-    this.$onChanges = (changes) => {
-      if (changes.queryId) {
-        Query.resultById({ id: this.queryId }, (result) => {
-          const queryResult = result.query_result;
-          this.queryResultOptions = optionsFromQueryResult(queryResult);
-          updateCurrentValue(this.param, this.queryResultOptions);
-        });
-      }
-    };
-  },
-};
-
 function ParametersDirective($location, $uibModal) {
   return {
     restrict: 'E',
@@ -160,33 +89,9 @@ function ParametersDirective($location, $uibModal) {
   };
 }
 
-const ParameterInputComponent = {
-  template: parameterInputTemplate,
-  bindings: {
-    param: '<',
-  },
-  controller($scope) {
-    // These are input as newline delimited values,
-    // so we split them here.
-    this.extractEnumOptions = (enumOptions) => {
-      if (enumOptions) {
-        return enumOptions.split('\n');
-      }
-      return [];
-    };
-
-    $scope.setParamValue = (value) => {
-      this.param.setValue(value);
-      $scope.$applyAsync();
-    };
-  },
-};
-
 export default function init(ngModule) {
   ngModule.directive('parameters', ParametersDirective);
-  ngModule.component('queryBasedParameter', QueryBasedParameterComponent);
   ngModule.component('parameterSettings', ParameterSettingsComponent);
-  ngModule.component('parameterInput', ParameterInputComponent);
 }
 
 init.init = true;
diff --git a/client/app/components/query-based-parameter.html b/client/app/components/query-based-parameter.html
deleted file mode 100644
index 4c553cbc7e..0000000000
--- a/client/app/components/query-based-parameter.html
+++ /dev/null
@@ -1,2 +0,0 @@
-<select ng-model="$ctrl.param.value" class="form-control" ng-options="option.value as option.name for option in $ctrl.queryResultOptions">
-</select>
\ No newline at end of file
diff --git a/client/app/lib/highlight.js b/client/app/lib/highlight.js
new file mode 100644
index 0000000000..322a66f722
--- /dev/null
+++ b/client/app/lib/highlight.js
@@ -0,0 +1,8 @@
+function escapeRegexp(queryToEscape) {
+  return ('' + queryToEscape).replace(/([.?*+^$[\]\\(){}|-])/g, '\\$1');
+}
+
+// https://github.com/angular-ui/ui-select/blob/master/src/common.js#L146
+export default function highlight(matchItem, query, template = '<span class="ui-select-highlight">$&</span>') {
+  return query && matchItem ? ('' + matchItem).replace(new RegExp(escapeRegexp(query), 'gi'), template) : matchItem;
+}
diff --git a/client/app/pages/dashboards/dashboard.html b/client/app/pages/dashboards/dashboard.html
index 3174a50549..0e5a675f9e 100644
--- a/client/app/pages/dashboards/dashboard.html
+++ b/client/app/pages/dashboards/dashboard.html
@@ -79,7 +79,7 @@ <h3>
   </div>
 
   <div class="m-b-10 p-15 bg-white tiled" ng-if="$ctrl.globalParameters.length > 0">
-    <parameters parameters="$ctrl.globalParameters" on-change="$ctrl.onGlobalParametersChange()"></parameters>
+    <parameters parameters="$ctrl.globalParameters"></parameters>
   </div>
 
   <div class="m-b-10 p-15 bg-white tiled" ng-if="$ctrl.filters | notEmpty">
@@ -105,6 +105,9 @@ <h2>
       <i class="zmdi zmdi-widgets"></i>
       <span class="hidden-xs hidden-sm">Widgets are individual query visualizations or text boxes you can place on your dashboard in various arrangements.</span>
     </h2>
-    <a class="btn btn-primary" ng-click="$ctrl.addWidget()">Add Widget</a>
+    <div>
+      <a class="btn btn-default" ng-click="$ctrl.addWidget('textbox')">Add Textbox</a>
+      <a class="btn btn-primary m-l-10" ng-click="$ctrl.addWidget('widget')">Add Widget</a>
+    </div>
   </div>
 </div>
diff --git a/client/app/pages/dashboards/dashboard.js b/client/app/pages/dashboards/dashboard.js
index 2d0c0e422e..9e77d1e57a 100644
--- a/client/app/pages/dashboards/dashboard.js
+++ b/client/app/pages/dashboards/dashboard.js
@@ -28,6 +28,7 @@ function DashboardCtrl(
   $timeout,
   $q,
   $uibModal,
+  $scope,
   Title,
   AlertDialog,
   Dashboard,
@@ -100,35 +101,18 @@ function DashboardCtrl(
   };
 
   this.extractGlobalParameters = () => {
-    let globalParams = {};
-    this.dashboard.widgets.forEach((widget) => {
-      if (widget.getQuery()) {
-        widget
-          .getQuery()
-          .getParametersDefs()
-          .filter(p => p.global)
-          .forEach((param) => {
-            const defaults = {};
-            defaults[param.name] = param.clone();
-            defaults[param.name].locals = [];
-            globalParams = _.defaults(globalParams, defaults);
-            globalParams[param.name].locals.push(param);
-          });
-      }
-    });
-    this.globalParameters = _.values(globalParams);
+    this.globalParameters = this.dashboard.getParametersDefs();
   };
 
-  this.onGlobalParametersChange = () => {
-    this.globalParameters.forEach((global) => {
-      global.locals.forEach((local) => {
-        local.value = global.value;
-      });
-    });
-  };
+  $scope.$on('dashboard.update-parameters', () => {
+    this.extractGlobalParameters();
+  });
 
   const collectFilters = (dashboard, forceRefresh) => {
-    const queryResultPromises = _.compact(this.dashboard.widgets.map(widget => widget.load(forceRefresh)));
+    const queryResultPromises = _.compact(this.dashboard.widgets.map((widget) => {
+      widget.getParametersDefs(); // Force widget to read parameters values from URL
+      return widget.load(forceRefresh);
+    }));
 
     $q.all(queryResultPromises).then((queryResults) => {
       const filters = {};
@@ -334,10 +318,14 @@ function DashboardCtrl(
     );
   };
 
-  this.addWidget = () => {
+  this.addWidget = (widgetType) => {
+    const widgetTypes = {
+      textbox: 'addTextboxDialog',
+      widget: 'addWidgetDialog',
+    };
     $uibModal
       .open({
-        component: 'addWidgetDialog',
+        component: widgetTypes[widgetType],
         resolve: {
           dashboard: () => this.dashboard,
         },
diff --git a/client/app/services/dashboard.js b/client/app/services/dashboard.js
index 625fb50d51..bb63aea261 100644
--- a/client/app/services/dashboard.js
+++ b/client/app/services/dashboard.js
@@ -44,7 +44,7 @@ function prepareWidgetsForDashboard(widgets) {
   return widgets;
 }
 
-function Dashboard($resource, $http, currentUser, Widget, dashboardGridOptions) {
+function Dashboard($resource, $http, $location, currentUser, Widget, dashboardGridOptions) {
   function prepareDashboardWidgets(widgets) {
     return prepareWidgetsForDashboard(_.map(widgets, widget => new Widget(widget)));
   }
@@ -151,6 +151,34 @@ function Dashboard($resource, $http, currentUser, Widget, dashboardGridOptions)
   resource.prepareDashboardWidgets = prepareDashboardWidgets;
   resource.prepareWidgetsForDashboard = prepareWidgetsForDashboard;
 
+  resource.prototype.getParametersDefs = function getParametersDefs() {
+    const globalParams = {};
+    const queryParams = $location.search();
+    _.each(this.widgets, (widget) => {
+      if (widget.getQuery()) {
+        const mappings = widget.getParameterMappings();
+        widget
+          .getQuery()
+          .getParametersDefs()
+          .forEach((param) => {
+            const mapping = mappings[param.name];
+            if (mapping.type === Widget.MappingType.DashboardLevel) {
+              if (!globalParams[mapping.mapTo]) {
+                globalParams[mapping.mapTo] = param.clone();
+                globalParams[mapping.mapTo].name = mapping.mapTo;
+                globalParams[mapping.mapTo].title = mapping.title || param.title;
+                globalParams[mapping.mapTo].locals = [];
+              }
+              globalParams[mapping.mapTo].locals.push(param);
+            }
+          });
+      }
+    });
+    return _.values(_.each(globalParams, (param) => {
+      param.fromUrlParams(queryParams);
+    }));
+  };
+
   return resource;
 }
 
diff --git a/client/app/services/query.js b/client/app/services/query.js
index dfa73a2186..e56f411bf2 100644
--- a/client/app/services/query.js
+++ b/client/app/services/query.js
@@ -53,12 +53,18 @@ class Parameter {
     this.name = parameter.name;
     this.type = parameter.type;
     this.useCurrentDateTime = parameter.useCurrentDateTime;
-    this.global = parameter.global;
+    this.global = parameter.global; // backward compatibility in Widget service
     this.enumOptions = parameter.enumOptions;
     this.queryId = parameter.queryId;
 
+    // Used for meta-parameters (i.e. dashboard-level params)
+    this.locals = [];
+
     // validate value and init internal state
     this.setValue(parameter.value);
+
+    // Used for URL serialization
+    this.urlPrefix = 'p_';
   }
 
   clone() {
@@ -121,6 +127,12 @@ class Parameter {
       this.value = value;
       this.$$value = value;
     }
+
+    if (isArray(this.locals)) {
+      each(this.locals, (local) => {
+        local.setValue(this.value);
+      });
+    }
   }
 
   get normalizedValue() {
@@ -139,26 +151,28 @@ class Parameter {
     if (this.isEmpty) {
       return {};
     }
+    const prefix = this.urlPrefix;
     if (isDateRangeParameter(this.type)) {
       return {
-        [`p_${this.name}.start`]: this.value.start,
-        [`p_${this.name}.end`]: this.value.end,
+        [`${prefix}${this.name}.start`]: this.value.start,
+        [`${prefix}${this.name}.end`]: this.value.end,
       };
     }
     return {
-      [`p_${this.name}`]: this.value,
+      [`${prefix}${this.name}`]: this.value,
     };
   }
 
   fromUrlParams(query) {
+    const prefix = this.urlPrefix;
     if (isDateRangeParameter(this.type)) {
-      const keyStart = `p_${this.name}.start`;
-      const keyEnd = `p_${this.name}.end`;
+      const keyStart = `${prefix}${this.name}.start`;
+      const keyEnd = `${prefix}${this.name}.end`;
       if (has(query, keyStart) && has(query, keyEnd)) {
         this.setValue([query[keyStart], query[keyEnd]]);
       }
     } else {
-      const key = `p_${this.name}`;
+      const key = `${prefix}${this.name}`;
       if (has(query, key)) {
         this.setValue(query[key]);
       }
diff --git a/client/app/services/widget.js b/client/app/services/widget.js
index a4df4b1f95..84a5bed785 100644
--- a/client/app/services/widget.js
+++ b/client/app/services/widget.js
@@ -1,5 +1,5 @@
 import moment from 'moment';
-import { each, pick, extend, isObject, truncate } from 'lodash';
+import { each, pick, extend, isObject, truncate, keys, difference, filter, map } from 'lodash';
 
 function calculatePositionOptions(Visualization, dashboardGridOptions, widget) {
   widget.width = 1; // Backward compatibility, user on back-end
@@ -61,8 +61,16 @@ function calculatePositionOptions(Visualization, dashboardGridOptions, widget) {
   return visualizationOptions;
 }
 
-function WidgetFactory($http, Query, Visualization, dashboardGridOptions) {
+export const ParameterMappingType = {
+  DashboardLevel: 'dashboard-level',
+  WidgetLevel: 'widget-level',
+  StaticValue: 'static-value',
+};
+
+function WidgetFactory($http, $location, Query, Visualization, dashboardGridOptions) {
   class Widget {
+    static MappingType = ParameterMappingType;
+
     constructor(data) {
       // Copy properties
       each(data, (v, k) => {
@@ -159,6 +167,69 @@ function WidgetFactory($http, Query, Visualization, dashboardGridOptions) {
       const url = `api/widgets/${this.id}`;
       return $http.delete(url);
     }
+
+    getParametersDefs() {
+      const mappings = this.getParameterMappings();
+      const params = this.getQuery().getParametersDefs();
+
+      each(params, (param) => {
+        param.mappingType = mappings[param.name].type;
+        if (mappings[param.name].type === Widget.MappingType.StaticValue) {
+          param.setValue(mappings[param.name].value);
+        }
+      });
+
+      const queryParams = $location.search();
+
+      const localTypes = [
+        Widget.MappingType.WidgetLevel,
+        Widget.MappingType.StaticValue,
+      ];
+      return map(
+        filter(params, param => localTypes.indexOf(mappings[param.name].type) >= 0),
+        (param) => {
+          const result = param.clone();
+          result.title = mappings[param.name].title || param.title;
+          result.locals = [param];
+          result.urlPrefix = `w${this.id}_`;
+          result.fromUrlParams(queryParams);
+          return result;
+        },
+      );
+    }
+
+    getParameterMappings() {
+      if (!isObject(this.options.parameterMappings)) {
+        this.options.parameterMappings = {};
+      }
+
+      const existingParams = {};
+      each(this.getQuery().getParametersDefs(), (param) => {
+        existingParams[param.name] = true;
+        if (!isObject(this.options.parameterMappings[param.name])) {
+          // "migration" for old dashboards: parameters with `global` flag
+          // should be mapped to a dashboard-level parameter with the same name
+          this.options.parameterMappings[param.name] = {
+            name: param.name,
+            type: param.global ? Widget.MappingType.DashboardLevel : Widget.MappingType.WidgetLevel,
+            mapTo: param.name, // map to param with the same name
+            value: null, // for StaticValue
+            title: '', // Use parameter's title
+          };
+        }
+      });
+
+      // Remove mappings for parameters that do not exists anymore
+      const removedParams = difference(
+        keys(this.options.parameterMappings),
+        keys(existingParams),
+      );
+      each(removedParams, (name) => {
+        delete this.options.parameterMappings[name];
+      });
+
+      return this.options.parameterMappings;
+    }
   }
 
   return Widget;
diff --git a/package.json b/package.json
index 565de8fd4d..b45a3e60f7 100644
--- a/package.json
+++ b/package.json
@@ -46,7 +46,7 @@
     "angular-ui-ace": "^0.2.3",
     "angular-ui-bootstrap": "^2.5.0",
     "angular-vs-repeat": "^1.1.7",
-    "antd": "^3.7.1",
+    "antd": "^3.11.2",
     "bootstrap": "^3.3.7",
     "brace": "^0.11.0",
     "chroma-js": "^1.3.6",


Code review comments:
diff_hunk: @@ -0,0 +1,267 @@
+/* eslint react/no-multi-comp: 0 */
+
+import { extend, map, includes, findIndex, find, fromPairs } from 'lodash';
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Select } from 'antd';
path: client/app/components/ParameterMappingInput.jsx
text: @user1:
Shouldn't this be `antd/lib/select`?