Browse Source

Merge remote-tracking branch 'origin/develop' into 14409/threshold-ux-changes

Peter Holmberg 7 năm trước cách đây
mục cha
commit
c78d5fb24e
47 tập tin đã thay đổi với 1318 bổ sung762 xóa
  1. 7 6
      public/app/core/components/Picker/DescriptionOption.tsx
  2. 2 2
      public/app/core/components/Picker/PickerOption.test.tsx
  3. 29 7
      public/app/core/components/Picker/PickerOption.tsx
  4. 60 0
      public/app/core/components/Picker/Select.tsx
  5. 5 3
      public/app/core/components/Picker/SimplePicker.tsx
  6. 1 1
      public/app/core/components/Picker/TeamPicker.tsx
  7. 1 1
      public/app/core/components/Picker/Unit/UnitPicker.tsx
  8. 1 1
      public/app/core/components/Picker/UserPicker.tsx
  9. 9 5
      public/app/core/components/Picker/__snapshots__/PickerOption.test.tsx.snap
  10. 2 1
      public/app/features/dashboard/dashboard_migration.ts
  11. 0 10
      public/app/features/dashboard/dashboard_model.ts
  12. 54 88
      public/app/features/dashboard/dashgrid/DataSourcePicker.tsx
  13. 15 17
      public/app/features/dashboard/dashgrid/EditorTabBody.tsx
  14. 172 85
      public/app/features/dashboard/dashgrid/QueriesTab.tsx
  15. 92 22
      public/app/features/dashboard/dashgrid/VisualizationTab.tsx
  16. 10 70
      public/app/features/dashboard/dashgrid/VizTypePicker.tsx
  17. 4 7
      public/app/features/dashboard/dashgrid/VizTypePickerPlugin.tsx
  18. 19 0
      public/app/features/dashboard/panel_model.ts
  19. 9 167
      public/app/features/panel/metrics_tab.ts
  20. 17 20
      public/app/features/panel/partials/metrics_tab.html
  21. 1 1
      public/app/features/panel/query_editor_row.ts
  22. 2 2
      public/app/plugins/datasource/prometheus/partials/query.editor.html
  23. 155 0
      public/app/plugins/panel/gauge/MappingRow.tsx
  24. 23 16
      public/app/plugins/panel/gauge/Threshold.test.tsx
  25. 2 11
      public/app/plugins/panel/gauge/Thresholds.tsx
  26. 73 0
      public/app/plugins/panel/gauge/ValueMappings.test.tsx
  27. 100 0
      public/app/plugins/panel/gauge/ValueMappings.tsx
  28. 61 0
      public/app/plugins/panel/gauge/__snapshots__/ValueMappings.test.tsx.snap
  29. 28 4
      public/app/plugins/panel/gauge/module.tsx
  30. 153 163
      public/app/plugins/panel/table/column_options.html
  31. 5 1
      public/app/types/index.ts
  32. 27 0
      public/app/types/panel.ts
  33. 47 10
      public/app/viz/Gauge.tsx
  34. 1 2
      public/sass/_grafana.scss
  35. 4 3
      public/sass/_variables.dark.scss
  36. 1 0
      public/sass/_variables.light.scss
  37. 1 0
      public/sass/components/_buttons.scss
  38. 0 11
      public/sass/components/_description-picker.scss
  39. 43 3
      public/sass/components/_form_select_box.scss
  40. 0 1
      public/sass/components/_gf-form.scss
  41. 5 3
      public/sass/components/_json_explorer.scss
  42. 30 5
      public/sass/components/_panel_editor.scss
  43. 2 1
      public/sass/components/_thresholds.scss
  44. 0 12
      public/sass/components/_user-picker.scss
  45. 37 0
      public/sass/components/_value-mappings.scss
  46. 4 0
      public/sass/components/edit_sidemenu.scss
  47. 4 0
      public/sass/utils/_utils.scss

+ 7 - 6
public/app/core/components/Picker/DescriptionOption.tsx

@@ -8,15 +8,16 @@ interface ExtendedOptionProps extends OptionProps<any> {
 }
 }
 
 
 export const Option = (props: ExtendedOptionProps) => {
 export const Option = (props: ExtendedOptionProps) => {
-  const { children, isSelected, data, className } = props;
+  const { children, isSelected, data } = props;
+
   return (
   return (
     <components.Option {...props}>
     <components.Option {...props}>
-      <div className={`description-picker-option__button btn btn-link ${className}`}>
-        {isSelected && <i className="fa fa-check pull-right" aria-hidden="true" />}
-        <div className="gf-form">{children}</div>
-        <div className="gf-form">
-          <div className="muted width-17">{data.description}</div>
+      <div className="gf-form-select-box__desc-option">
+        <div className="gf-form-select-box__desc-option__body">
+          <div>{children}</div>
+          {data.description && <div className="gf-form-select-box__desc-option__desc">{data.description}</div>}
         </div>
         </div>
+        {isSelected && <i className="fa fa-check" aria-hidden="true" />}
       </div>
       </div>
     </components.Option>
     </components.Option>
   );
   );

+ 2 - 2
public/app/core/components/Picker/PickerOption.test.tsx

@@ -1,4 +1,4 @@
-import React from 'react';
+import React from 'react';
 import renderer from 'react-test-renderer';
 import renderer from 'react-test-renderer';
 import PickerOption from './PickerOption';
 import PickerOption from './PickerOption';
 
 
@@ -24,7 +24,7 @@ const model = {
   children: 'Model title',
   children: 'Model title',
   data: {
   data: {
     title: 'Model title',
     title: 'Model title',
-    avatarUrl: 'url/to/avatar',
+    imgUrl: 'url/to/avatar',
     label: 'User picker label',
     label: 'User picker label',
   },
   },
   className: 'class-for-user-picker',
   className: 'class-for-user-picker',

+ 29 - 7
public/app/core/components/Picker/PickerOption.tsx

@@ -4,19 +4,41 @@ import { OptionProps } from 'react-select/lib/components/Option';
 
 
 // https://github.com/JedWatson/react-select/issues/3038
 // https://github.com/JedWatson/react-select/issues/3038
 interface ExtendedOptionProps extends OptionProps<any> {
 interface ExtendedOptionProps extends OptionProps<any> {
-  data: any;
+  data: {
+    description?: string;
+    imgUrl?: string;
+  };
 }
 }
 
 
-export const PickerOption = (props: ExtendedOptionProps) => {
-  const { children, data, className } = props;
+export const Option = (props: ExtendedOptionProps) => {
+  const { children, isSelected, data } = props;
+
   return (
   return (
     <components.Option {...props}>
     <components.Option {...props}>
-      <div className={`description-picker-option__button btn btn-link ${className}`}>
-        {data.avatarUrl && <img src={data.avatarUrl} alt={data.label} className="user-picker-option__avatar" />}
-        {children}
+      <div className="gf-form-select-box__desc-option">
+        {data.imgUrl && <img className="gf-form-select-box__desc-option__img" src={data.imgUrl} />}
+        <div className="gf-form-select-box__desc-option__body">
+          <div>{children}</div>
+          {data.description && <div className="gf-form-select-box__desc-option__desc">{data.description}</div>}
+        </div>
+        {isSelected && <i className="fa fa-check" aria-hidden="true" />}
       </div>
       </div>
     </components.Option>
     </components.Option>
   );
   );
 };
 };
 
 
-export default PickerOption;
+// was not able to type this without typescript error
+export const SingleValue = props => {
+  const { children, data } = props;
+
+  return (
+    <components.SingleValue {...props}>
+      <div className="gf-form-select-box__img-value">
+        {data.imgUrl && <img className="gf-form-select-box__desc-option__img" src={data.imgUrl} />}
+        {children}
+      </div>
+    </components.SingleValue>
+  );
+};
+
+export default Option;

+ 60 - 0
public/app/core/components/Picker/Select.tsx

@@ -0,0 +1,60 @@
+// import React, { PureComponent } from 'react';
+// import Select as ReactSelect from 'react-select';
+// import DescriptionOption from './DescriptionOption';
+// import IndicatorsContainer from './IndicatorsContainer';
+// import ResetStyles from './ResetStyles';
+//
+// export interface OptionType {
+//   label: string;
+//   value: string;
+// }
+//
+// interface Props {
+//   defaultValue?: any;
+//   getOptionLabel: (item: T) => string;
+//   getOptionValue: (item: T) => string;
+//   onChange: (item: T) => {} | void;
+//   options: T[];
+//   placeholder?: string;
+//   width?: number;
+//   value: T;
+//   className?: string;
+// }
+//
+// export class Select<T> extends PureComponent<Props<T>> {
+//   static defaultProps = {
+//     width: null,
+//     className: '',
+//   }
+//
+//   render() {
+//     const { defaultValue, getOptionLabel, getOptionValue, onSelected, options, placeholder, width, value, className } = this.props;
+//     let widthClass = '';
+//     if (width) {
+//       widthClass = 'width-'+width;
+//     }
+//
+//   return (
+//     <ReactSelect
+//       classNamePrefix="gf-form-select-box"
+//       className={`gf-form-input gf-form-input--form-dropdown ${widthClass} ${className}`}
+//       components={{
+//         Option: DescriptionOption,
+//         IndicatorsContainer,
+//       }}
+//       defaultValue={defaultValue}
+//       value={value}
+//       getOptionLabel={getOptionLabel}
+//       getOptionValue={getOptionValue}
+//       menuShouldScrollIntoView={false}
+//       isSearchable={false}
+//       onChange={onSelected}
+//       options={options}
+//       placeholder={placeholder || 'Choose'}
+//       styles={ResetStyles}
+//     />
+//   );
+// }
+// }
+//
+// export default Select;

+ 5 - 3
public/app/core/components/Picker/SimplePicker.tsx

@@ -1,6 +1,7 @@
 import React, { SFC } from 'react';
 import React, { SFC } from 'react';
 import Select from 'react-select';
 import Select from 'react-select';
 import DescriptionOption from './DescriptionOption';
 import DescriptionOption from './DescriptionOption';
+import IndicatorsContainer from './IndicatorsContainer';
 import ResetStyles from './ResetStyles';
 import ResetStyles from './ResetStyles';
 
 
 interface Props {
 interface Props {
@@ -11,7 +12,7 @@ interface Props {
   onSelected: (item: any) => {} | void;
   onSelected: (item: any) => {} | void;
   options: any[];
   options: any[];
   placeholder?: string;
   placeholder?: string;
-  width: number;
+  width?: number;
   value: any;
   value: any;
 }
 }
 
 
@@ -28,10 +29,11 @@ const SimplePicker: SFC<Props> = ({
 }) => {
 }) => {
   return (
   return (
     <Select
     <Select
-      classNamePrefix={`gf-form-select-box`}
-      className={`width-${width} gf-form-input gf-form-input--form-dropdown ${className || ''}`}
+      classNamePrefix="gf-form-select-box"
+      className={`${width ? 'width-' + width : ''} gf-form-input gf-form-input--form-dropdown ${className || ''}`}
       components={{
       components={{
         Option: DescriptionOption,
         Option: DescriptionOption,
+        IndicatorsContainer,
       }}
       }}
       defaultValue={defaultValue}
       defaultValue={defaultValue}
       value={value}
       value={value}

+ 1 - 1
public/app/core/components/Picker/TeamPicker.tsx

@@ -47,7 +47,7 @@ export class TeamPicker extends Component<Props, State> {
           id: team.id,
           id: team.id,
           label: team.name,
           label: team.name,
           name: team.name,
           name: team.name,
-          avatarUrl: team.avatarUrl,
+          imgUrl: team.avatarUrl,
         };
         };
       });
       });
 
 

+ 1 - 1
public/app/core/components/Picker/Unit/UnitPicker.tsx

@@ -63,7 +63,7 @@ export default class UnitPicker extends PureComponent<Props> {
     return (
     return (
       <Select
       <Select
         classNamePrefix="gf-form-select-box"
         classNamePrefix="gf-form-select-box"
-        className={`width-${width} gf-form-input--form-dropdown`}
+        className={`width-${width} gf-form-input gf-form-input--form-dropdown`}
         defaultValue={value}
         defaultValue={value}
         isSearchable={true}
         isSearchable={true}
         menuShouldScrollIntoView={false}
         menuShouldScrollIntoView={false}

+ 1 - 1
public/app/core/components/Picker/UserPicker.tsx

@@ -41,7 +41,7 @@ export class UserPicker extends Component<Props, State> {
         return result.map(user => ({
         return result.map(user => ({
           id: user.userId,
           id: user.userId,
           label: user.login === user.email ? user.login : `${user.login} - ${user.email}`,
           label: user.login === user.email ? user.login : `${user.login} - ${user.email}`,
-          avatarUrl: user.avatarUrl,
+          imgUrl: user.avatarUrl,
           login: user.login,
           login: user.login,
         }));
         }));
       })
       })

+ 9 - 5
public/app/core/components/Picker/__snapshots__/PickerOption.test.tsx.snap

@@ -3,15 +3,19 @@
 exports[`PickerOption renders correctly 1`] = `
 exports[`PickerOption renders correctly 1`] = `
 <div>
 <div>
   <div
   <div
-    className="description-picker-option__button btn btn-link class-for-user-picker"
+    className="gf-form-select-box__desc-option"
   >
   >
     <img
     <img
-      alt="User picker label"
-      className="user-picker-option__avatar"
+      className="gf-form-select-box__desc-option__img"
       src="url/to/avatar"
       src="url/to/avatar"
     />
     />
-    Model title
+    <div
+      className="gf-form-select-box__desc-option__body"
+    >
+      <div>
+        Model title
+      </div>
+    </div>
   </div>
   </div>
 </div>
 </div>
 `;
 `;
-  

+ 2 - 1
public/app/features/dashboard/dashboard_migration.ts

@@ -141,9 +141,10 @@ export class DashboardMigrator {
 
 
       // ensure query refIds
       // ensure query refIds
       panelUpgrades.push(panel => {
       panelUpgrades.push(panel => {
+        console.log('asdasd', panel);
         _.each(panel.targets, target => {
         _.each(panel.targets, target => {
           if (!target.refId) {
           if (!target.refId) {
-            target.refId = this.dashboard.getNextQueryLetter(panel);
+            target.refId = panel.getNextQueryLetter && panel.getNextQueryLetter();
           }
           }
         });
         });
       });
       });

+ 0 - 10
public/app/features/dashboard/dashboard_model.ts

@@ -806,16 +806,6 @@ export class DashboardModel {
     return this.timezone === 'browser' ? moment(date).fromNow() : moment.utc(date).fromNow();
     return this.timezone === 'browser' ? moment(date).fromNow() : moment.utc(date).fromNow();
   }
   }
 
 
-  getNextQueryLetter(panel) {
-    const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
-
-    return _.find(letters, refId => {
-      return _.every(panel.targets, other => {
-        return other.refId !== refId;
-      });
-    });
-  }
-
   isTimezoneUtc() {
   isTimezoneUtc() {
     return this.getTimezone() === 'utc';
     return this.getTimezone() === 'utc';
   }
   }

+ 54 - 88
public/app/features/dashboard/dashgrid/DataSourcePicker.tsx

@@ -1,115 +1,81 @@
+// Libraries
 import React, { PureComponent } from 'react';
 import React, { PureComponent } from 'react';
-import classNames from 'classnames';
 import _ from 'lodash';
 import _ from 'lodash';
-import KeyboardNavigation, { KeyboardNavigationProps } from './KeyboardNavigation';
+
+// Components
+import ResetStyles from 'app/core/components/Picker/ResetStyles';
+import { Option, SingleValue } from 'app/core/components/Picker/PickerOption';
+import IndicatorsContainer from 'app/core/components/Picker/IndicatorsContainer';
+import Select from 'react-select';
+
+// Types
 import { DataSourceSelectItem } from 'app/types';
 import { DataSourceSelectItem } from 'app/types';
 
 
 export interface Props {
 export interface Props {
   onChangeDataSource: (ds: DataSourceSelectItem) => void;
   onChangeDataSource: (ds: DataSourceSelectItem) => void;
   datasources: DataSourceSelectItem[];
   datasources: DataSourceSelectItem[];
+  current: DataSourceSelectItem;
+  onBlur?: () => void;
+  autoFocus?: boolean;
 }
 }
 
 
-interface State {
-  searchQuery: string;
-}
+export class DataSourcePicker extends PureComponent<Props> {
+  static defaultProps = {
+    autoFocus: false,
+  };
 
 
-export class DataSourcePicker extends PureComponent<Props, State> {
   searchInput: HTMLElement;
   searchInput: HTMLElement;
 
 
   constructor(props) {
   constructor(props) {
     super(props);
     super(props);
-    this.state = {
-      searchQuery: '',
-    };
   }
   }
 
 
-  getDataSources() {
-    const { searchQuery } = this.state;
-    const regex = new RegExp(searchQuery, 'i');
-    const { datasources } = this.props;
-
-    const filtered = datasources.filter(item => {
-      return regex.test(item.name) || regex.test(item.meta.name);
-    });
-
-    return filtered;
-  }
-
-  get maxSelectedIndex() {
-    const filtered = this.getDataSources();
-    return filtered.length - 1;
-  }
-
-  renderDataSource = (ds: DataSourceSelectItem, index: number, keyNavProps: KeyboardNavigationProps) => {
-    const { onChangeDataSource } = this.props;
-    const { selected, onMouseEnter } = keyNavProps;
-    const onClick = () => onChangeDataSource(ds);
-    const isSelected = selected === index;
-    const cssClass = classNames({
-      'ds-picker-list__item': true,
-      'ds-picker-list__item--selected': isSelected,
-    });
-    return (
-      <div key={index} className={cssClass} title={ds.name} onClick={onClick} onMouseEnter={() => onMouseEnter(index)}>
-        <img className="ds-picker-list__img" src={ds.meta.info.logos.small} />
-        <div className="ds-picker-list__name">{ds.name}</div>
-      </div>
-    );
+  onChange = item => {
+    const ds = this.props.datasources.find(ds => ds.name === item.value);
+    this.props.onChangeDataSource(ds);
   };
   };
 
 
-  componentDidMount() {
-    setTimeout(() => {
-      this.searchInput.focus();
-    }, 300);
-  }
+  render() {
+    const { datasources, current, autoFocus, onBlur } = this.props;
 
 
-  onSearchQueryChange = evt => {
-    const value = evt.target.value;
-    this.setState(prevState => ({
-      ...prevState,
-      searchQuery: value,
+    const options = datasources.map(ds => ({
+      value: ds.name,
+      label: ds.name,
+      imgUrl: ds.meta.info.logos.small,
     }));
     }));
-  };
 
 
-  renderFilters({ onKeyDown, selected }: KeyboardNavigationProps) {
-    const { searchQuery } = this.state;
+    const value = current && {
+      label: current.name,
+      value: current.name,
+      imgUrl: current.meta.info.logos.small,
+    };
+
     return (
     return (
-      <label className="gf-form--has-input-icon">
-        <input
-          type="text"
-          className="gf-form-input width-13"
-          placeholder=""
-          ref={elem => (this.searchInput = elem)}
-          onChange={this.onSearchQueryChange}
-          value={searchQuery}
-          onKeyDown={evt => {
-            onKeyDown(evt, this.maxSelectedIndex, () => {
-              const { onChangeDataSource } = this.props;
-              const ds = this.getDataSources()[selected];
-              onChangeDataSource(ds);
-            });
+      <div className="gf-form-inline">
+        <Select
+          classNamePrefix={`gf-form-select-box`}
+          isMulti={false}
+          menuShouldScrollIntoView={false}
+          isClearable={false}
+          className="gf-form-input gf-form-input--form-dropdown ds-picker"
+          onChange={item => this.onChange(item)}
+          options={options}
+          styles={ResetStyles}
+          autoFocus={autoFocus}
+          onBlur={onBlur}
+          openMenuOnFocus={true}
+          maxMenuHeight={500}
+          placeholder="Select datasource"
+          loadingMessage={() => 'Loading datasources...'}
+          noOptionsMessage={() => 'No datasources found'}
+          value={value}
+          components={{
+            Option,
+            SingleValue,
+            IndicatorsContainer,
           }}
           }}
         />
         />
-        <i className="gf-form-input-icon fa fa-search" />
-      </label>
-    );
-  }
-
-  render() {
-    return (
-      <KeyboardNavigation
-        render={(keyNavProps: KeyboardNavigationProps) => (
-          <>
-            <div className="cta-form__bar">
-              {this.renderFilters(keyNavProps)}
-              <div className="gf-form--grow" />
-            </div>
-            <div className="ds-picker-list">
-              {this.getDataSources().map((ds, index) => this.renderDataSource(ds, index, keyNavProps))}
-            </div>
-          </>
-        )}
-      />
+      </div>
     );
     );
   }
   }
 }
 }

+ 15 - 17
public/app/features/dashboard/dashgrid/EditorTabBody.tsx

@@ -5,8 +5,8 @@ import { FadeIn } from 'app/core/components/Animations/FadeIn';
 interface Props {
 interface Props {
   children: JSX.Element;
   children: JSX.Element;
   heading: string;
   heading: string;
-  main?: EditorToolBarView;
-  toolbarItems: EditorToolBarView[];
+  renderToolbar?: () => JSX.Element;
+  toolbarItems?: EditorToolBarView[];
 }
 }
 
 
 export interface EditorToolBarView {
 export interface EditorToolBarView {
@@ -15,7 +15,7 @@ export interface EditorToolBarView {
   icon?: string;
   icon?: string;
   disabled?: boolean;
   disabled?: boolean;
   onClick?: () => void;
   onClick?: () => void;
-  render: (closeFunction: any) => JSX.Element | JSX.Element[];
+  render: (closeFunction?: any) => JSX.Element | JSX.Element[];
 }
 }
 
 
 interface State {
 interface State {
@@ -25,6 +25,10 @@ interface State {
 }
 }
 
 
 export class EditorTabBody extends PureComponent<Props, State> {
 export class EditorTabBody extends PureComponent<Props, State> {
+  static defaultProps = {
+    toolbarItems: [],
+  };
+
   constructor(props) {
   constructor(props) {
     super(props);
     super(props);
 
 
@@ -65,16 +69,6 @@ export class EditorTabBody extends PureComponent<Props, State> {
     return state;
     return state;
   }
   }
 
 
-  renderMainSelection(view: EditorToolBarView) {
-    return (
-      <div className="toolbar__main" onClick={() => this.onToggleToolBarView(view)} key={view.title + view.icon}>
-        <img className="toolbar__main-image" src={view.imgSrc} />
-        <div className="toolbar__main-name">{view.title}</div>
-        <i className="fa fa-caret-down" />
-      </div>
-    );
-  }
-
   renderButton(view: EditorToolBarView) {
   renderButton(view: EditorToolBarView) {
     const onClick = () => {
     const onClick = () => {
       if (view.onClick) {
       if (view.onClick) {
@@ -104,16 +98,20 @@ export class EditorTabBody extends PureComponent<Props, State> {
   }
   }
 
 
   render() {
   render() {
-    const { children, toolbarItems, main, heading } = this.props;
+    const { children, renderToolbar, heading, toolbarItems } = this.props;
     const { openView, fadeIn, isOpen } = this.state;
     const { openView, fadeIn, isOpen } = this.state;
 
 
     return (
     return (
       <>
       <>
         <div className="toolbar">
         <div className="toolbar">
           <div className="toolbar__heading">{heading}</div>
           <div className="toolbar__heading">{heading}</div>
-          {main && this.renderMainSelection(main)}
-          <div className="gf-form--grow" />
-          {toolbarItems.map(item => this.renderButton(item))}
+          {renderToolbar && renderToolbar()}
+          {toolbarItems.length > 0 && (
+            <>
+              <div className="gf-form--grow" />
+              {toolbarItems.map(item => this.renderButton(item))}
+            </>
+          )}
         </div>
         </div>
         <div className="panel-editor__scroll">
         <div className="panel-editor__scroll">
           <CustomScrollbar autoHide={false}>
           <CustomScrollbar autoHide={false}>

+ 172 - 85
public/app/features/dashboard/dashgrid/QueriesTab.tsx

@@ -1,36 +1,39 @@
+// Libraries
 import React, { SFC, PureComponent } from 'react';
 import React, { SFC, PureComponent } from 'react';
+import Remarkable from 'remarkable';
+import _ from 'lodash';
+
+// Components
 import DataSourceOption from './DataSourceOption';
 import DataSourceOption from './DataSourceOption';
-import { getAngularLoader, AngularComponent } from 'app/core/services/AngularLoader';
 import { EditorTabBody } from './EditorTabBody';
 import { EditorTabBody } from './EditorTabBody';
 import { DataSourcePicker } from './DataSourcePicker';
 import { DataSourcePicker } from './DataSourcePicker';
-import { PanelModel } from '../panel_model';
-import { DashboardModel } from '../dashboard_model';
-import './../../panel/metrics_tab';
-import config from 'app/core/config';
 import { QueryInspector } from './QueryInspector';
 import { QueryInspector } from './QueryInspector';
 import { TimeRangeOptions } from './TimeRangeOptions';
 import { TimeRangeOptions } from './TimeRangeOptions';
+import './../../panel/metrics_tab';
+import { AngularQueryComponentScope } from 'app/features/panel/metrics_tab';
 
 
 // Services
 // Services
 import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
 import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
 import { getBackendSrv, BackendSrv } from 'app/core/services/backend_srv';
 import { getBackendSrv, BackendSrv } from 'app/core/services/backend_srv';
-import { DataSourceSelectItem } from 'app/types';
+import { getAngularLoader, AngularComponent } from 'app/core/services/AngularLoader';
+import config from 'app/core/config';
 
 
-import Remarkable from 'remarkable';
+// Types
+import { PanelModel } from '../panel_model';
+import { DashboardModel } from '../dashboard_model';
+import { DataSourceSelectItem, DataQuery } from 'app/types';
 
 
 interface Props {
 interface Props {
   panel: PanelModel;
   panel: PanelModel;
   dashboard: DashboardModel;
   dashboard: DashboardModel;
 }
 }
 
 
-interface Help {
-  isLoading: boolean;
-  helpHtml: any;
-}
-
 interface State {
 interface State {
-  currentDatasource: DataSourceSelectItem;
-  help: Help;
-  hideTimeOverride: boolean;
+  currentDS: DataSourceSelectItem;
+  helpContent: JSX.Element;
+  isLoadingHelp: boolean;
+  isPickerOpen: boolean;
+  isAddingMixed: boolean;
 }
 }
 
 
 interface LoadingPlaceholderProps {
 interface LoadingPlaceholderProps {
@@ -40,7 +43,7 @@ interface LoadingPlaceholderProps {
 const LoadingPlaceholder: SFC<LoadingPlaceholderProps> = ({ text }) => <h2>{text}</h2>;
 const LoadingPlaceholder: SFC<LoadingPlaceholderProps> = ({ text }) => <h2>{text}</h2>;
 
 
 export class QueriesTab extends PureComponent<Props, State> {
 export class QueriesTab extends PureComponent<Props, State> {
-  element: any;
+  element: HTMLElement;
   component: AngularComponent;
   component: AngularComponent;
   datasources: DataSourceSelectItem[] = getDatasourceSrv().getMetricSources();
   datasources: DataSourceSelectItem[] = getDatasourceSrv().getMetricSources();
   backendSrv: BackendSrv = getBackendSrv();
   backendSrv: BackendSrv = getBackendSrv();
@@ -50,12 +53,26 @@ export class QueriesTab extends PureComponent<Props, State> {
     const { panel } = props;
     const { panel } = props;
 
 
     this.state = {
     this.state = {
-      currentDatasource: this.datasources.find(datasource => datasource.value === panel.datasource),
-      help: {
-        isLoading: false,
-        helpHtml: null,
-      },
-      hideTimeOverride: false,
+      currentDS: this.datasources.find(datasource => datasource.value === panel.datasource),
+      isLoadingHelp: false,
+      helpContent: null,
+      isPickerOpen: false,
+      isAddingMixed: false,
+    };
+  }
+
+  getAngularQueryComponentScope(): AngularQueryComponentScope {
+    const { panel, dashboard } = this.props;
+
+    return {
+      panel: panel,
+      dashboard: dashboard,
+      refresh: () => panel.refresh(),
+      render: () => panel.render,
+      addQuery: this.onAddQuery,
+      moveQuery: this.onMoveQuery,
+      removeQuery: this.onRemoveQuery,
+      events: panel.events,
     };
     };
   }
   }
 
 
@@ -64,15 +81,10 @@ export class QueriesTab extends PureComponent<Props, State> {
       return;
       return;
     }
     }
 
 
-    const { panel, dashboard } = this.props;
     const loader = getAngularLoader();
     const loader = getAngularLoader();
     const template = '<metrics-tab />';
     const template = '<metrics-tab />';
     const scopeProps = {
     const scopeProps = {
-      ctrl: {
-        panel: panel,
-        dashboard: dashboard,
-        refresh: () => panel.refresh(),
-      },
+      ctrl: this.getAngularQueryComponentScope(),
     };
     };
 
 
     this.component = loader.load(this.element, scopeProps, template);
     this.component = loader.load(this.element, scopeProps, template);
@@ -86,7 +98,8 @@ export class QueriesTab extends PureComponent<Props, State> {
 
 
   onChangeDataSource = datasource => {
   onChangeDataSource = datasource => {
     const { panel } = this.props;
     const { panel } = this.props;
-    const { currentDatasource } = this.state;
+    const { currentDS } = this.state;
+
     // switching to mixed
     // switching to mixed
     if (datasource.meta.mixed) {
     if (datasource.meta.mixed) {
       panel.targets.forEach(target => {
       panel.targets.forEach(target => {
@@ -95,62 +108,58 @@ export class QueriesTab extends PureComponent<Props, State> {
           target.datasource = config.defaultDatasource;
           target.datasource = config.defaultDatasource;
         }
         }
       });
       });
-    } else if (currentDatasource && currentDatasource.meta.mixed) {
-      panel.targets.forEach(target => {
-        delete target.datasource;
-      });
+    } else if (currentDS) {
+      // if switching from mixed
+      if (currentDS.meta.mixed) {
+        for (const target of panel.targets) {
+          delete target.datasource;
+        }
+      } else if (currentDS.meta.id !== datasource.meta.id) {
+        // we are changing data source type, clear queries
+        panel.targets = [{ refId: 'A' }];
+      }
     }
     }
 
 
     panel.datasource = datasource.value;
     panel.datasource = datasource.value;
     panel.refresh();
     panel.refresh();
 
 
-    this.setState(prevState => ({
-      ...prevState,
-      currentDatasource: datasource,
-    }));
+    this.setState({
+      currentDS: datasource,
+    });
   };
   };
 
 
   loadHelp = () => {
   loadHelp = () => {
-    const { currentDatasource } = this.state;
-    const hasHelp = currentDatasource.meta.hasQueryHelp;
+    const { currentDS } = this.state;
+    const hasHelp = currentDS.meta.hasQueryHelp;
 
 
     if (hasHelp) {
     if (hasHelp) {
-      this.setState(prevState => ({
-        ...prevState,
-        help: {
-          helpHtml: <h2>Loading help...</h2>,
-          isLoading: true,
-        },
-      }));
+      this.setState({
+        helpContent: <h3>Loading help...</h3>,
+        isLoadingHelp: true,
+      });
 
 
       this.backendSrv
       this.backendSrv
-        .get(`/api/plugins/${currentDatasource.meta.id}/markdown/query_help`)
+        .get(`/api/plugins/${currentDS.meta.id}/markdown/query_help`)
         .then(res => {
         .then(res => {
           const md = new Remarkable();
           const md = new Remarkable();
-          const helpHtml = md.render(res); // TODO: Clean out dangerous code? Previous: this.helpHtml = this.$sce.trustAsHtml(md.render(res));
-          this.setState(prevState => ({
-            ...prevState,
-            help: {
-              helpHtml: <div className="markdown-html" dangerouslySetInnerHTML={{ __html: helpHtml }} />,
-              isLoading: false,
-            },
-          }));
+          const helpHtml = md.render(res);
+          this.setState({
+            helpContent: <div className="markdown-html" dangerouslySetInnerHTML={{ __html: helpHtml }} />,
+            isLoadingHelp: false,
+          });
         })
         })
         .catch(() => {
         .catch(() => {
-          this.setState(prevState => ({
-            ...prevState,
-            help: {
-              helpHtml: 'Error occured when loading help',
-              isLoading: false,
-            },
-          }));
+          this.setState({
+            helpContent: <h3>'Error occured when loading help'</h3>,
+            isLoadingHelp: false,
+          });
         });
         });
     }
     }
   };
   };
 
 
   renderOptions = close => {
   renderOptions = close => {
-    const { currentDatasource } = this.state;
-    const { queryOptions } = currentDatasource.meta;
+    const { currentDS } = this.state;
+    const { queryOptions } = currentDS.meta;
     const { panel } = this.props;
     const { panel } = this.props;
 
 
     const onChangeFn = (panelKey: string) => {
     const onChangeFn = (panelKey: string) => {
@@ -223,26 +232,83 @@ export class QueriesTab extends PureComponent<Props, State> {
   };
   };
 
 
   renderHelp = () => {
   renderHelp = () => {
-    const { helpHtml, isLoading } = this.state.help;
-    return isLoading ? <LoadingPlaceholder text="Loading help..." /> : helpHtml;
+    const { helpContent, isLoadingHelp } = this.state;
+    return isLoadingHelp ? <LoadingPlaceholder text="Loading help..." /> : helpContent;
+  };
+
+  onAddQuery = (query?: Partial<DataQuery>) => {
+    this.props.panel.addQuery(query);
+    this.forceUpdate();
+  };
+
+  onAddQueryClick = () => {
+    if (this.state.currentDS.meta.mixed) {
+      this.setState({ isAddingMixed: true });
+      return;
+    }
+
+    this.props.panel.addQuery();
+    this.component.digest();
+    this.forceUpdate();
+  };
+
+  onRemoveQuery = (query: DataQuery) => {
+    const { panel } = this.props;
+
+    const index = _.indexOf(panel.targets, query);
+    panel.targets.splice(index, 1);
+    panel.refresh();
+
+    this.forceUpdate();
+  };
+
+  onMoveQuery = (query: DataQuery, direction: number) => {
+    const { panel } = this.props;
+
+    const index = _.indexOf(panel.targets, query);
+    _.move(panel.targets, index, index + direction);
+
+    this.forceUpdate();
+  };
+
+  renderToolbar = () => {
+    const { currentDS } = this.state;
+
+    return (
+      <DataSourcePicker
+        datasources={this.datasources}
+        onChangeDataSource={this.onChangeDataSource}
+        current={currentDS}
+      />
+    );
+  };
+
+  renderMixedPicker = () => {
+    return (
+      <DataSourcePicker
+        datasources={this.datasources}
+        onChangeDataSource={this.onAddMixedQuery}
+        current={null}
+        autoFocus={true}
+        onBlur={this.onMixedPickerBlur}
+      />
+    );
+  };
+
+  onAddMixedQuery = datasource => {
+    this.onAddQuery({ datasource: datasource.name });
+    this.component.digest();
+    this.setState({ isAddingMixed: false });
+  };
+
+  onMixedPickerBlur = () => {
+    this.setState({ isAddingMixed: false });
   };
   };
 
 
   render() {
   render() {
-    const { currentDatasource } = this.state;
-    const { hasQueryHelp } = currentDatasource.meta;
-    const dsInformation = {
-      title: currentDatasource.name,
-      imgSrc: currentDatasource.meta.info.logos.small,
-      render: closeOpenView => (
-        <DataSourcePicker
-          datasources={this.datasources}
-          onChangeDataSource={ds => {
-            closeOpenView();
-            this.onChangeDataSource(ds);
-          }}
-        />
-      ),
-    };
+    const { panel } = this.props;
+    const { currentDS, isAddingMixed } = this.state;
+    const { hasQueryHelp } = currentDS.meta;
 
 
     const queryInspector = {
     const queryInspector = {
       title: 'Query Inspector',
       title: 'Query Inspector',
@@ -265,10 +331,31 @@ export class QueriesTab extends PureComponent<Props, State> {
     };
     };
 
 
     return (
     return (
-      <EditorTabBody heading="Queries" main={dsInformation} toolbarItems={[options, queryInspector, dsHelp]}>
-        <>
-          <div ref={element => (this.element = element)} style={{ width: '100%' }} />
-        </>
+      <EditorTabBody
+        heading="Queries"
+        renderToolbar={this.renderToolbar}
+        toolbarItems={[options, queryInspector, dsHelp]}
+      >
+        <div className="query-editor-rows gf-form-group">
+          <div ref={element => (this.element = element)} />
+
+          <div className="gf-form-query">
+            <div className="gf-form gf-form-query-letter-cell">
+              <label className="gf-form-label">
+                <span className="gf-form-query-letter-cell-carret muted">
+                  <i className="fa fa-caret-down" />
+                </span>
+                <span className="gf-form-query-letter-cell-letter">{panel.getNextQueryLetter()}</span>
+              </label>
+              {!isAddingMixed && (
+                <button className="btn btn-secondary gf-form-btn" onClick={this.onAddQueryClick}>
+                  Add Query
+                </button>
+              )}
+              {isAddingMixed && this.renderMixedPicker()}
+            </div>
+          </div>
+        </div>
       </EditorTabBody>
       </EditorTabBody>
     );
     );
   }
   }

+ 92 - 22
public/app/features/dashboard/dashgrid/VisualizationTab.tsx

@@ -7,6 +7,7 @@ import { getAngularLoader, AngularComponent } from 'app/core/services/AngularLoa
 // Components
 // Components
 import { EditorTabBody } from './EditorTabBody';
 import { EditorTabBody } from './EditorTabBody';
 import { VizTypePicker } from './VizTypePicker';
 import { VizTypePicker } from './VizTypePicker';
+import { FadeIn } from 'app/core/components/Animations/FadeIn';
 
 
 // Types
 // Types
 import { PanelModel } from '../panel_model';
 import { PanelModel } from '../panel_model';
@@ -21,9 +22,24 @@ interface Props {
   onTypeChanged: (newType: PanelPlugin) => void;
   onTypeChanged: (newType: PanelPlugin) => void;
 }
 }
 
 
-export class VisualizationTab extends PureComponent<Props> {
+interface State {
+  isVizPickerOpen: boolean;
+  searchQuery: string;
+}
+
+export class VisualizationTab extends PureComponent<Props, State> {
   element: HTMLElement;
   element: HTMLElement;
   angularOptions: AngularComponent;
   angularOptions: AngularComponent;
+  searchInput: HTMLElement;
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      isVizPickerOpen: false,
+      searchQuery: '',
+    };
+  }
 
 
   getPanelDefaultOptions = () => {
   getPanelDefaultOptions = () => {
     const { panel, plugin } = this.props;
     const { panel, plugin } = this.props;
@@ -87,10 +103,11 @@ export class VisualizationTab extends PureComponent<Props> {
 
 
     let template = '';
     let template = '';
     for (let i = 0; i < panelCtrl.editorTabs.length; i++) {
     for (let i = 0; i < panelCtrl.editorTabs.length; i++) {
-      template += `
-      <div class="form-section" ng-cloak>
-        <div class="form-section__header">{{ctrl.editorTabs[${i}].title}}</div>
-        <div class="form-section__body">
+      template +=
+        `
+      <div class="form-section" ng-cloak>` +
+        (i > 0 ? `<div class="form-section__header">{{ctrl.editorTabs[${i}].title}}</div>` : '') +
+        `<div class="form-section__body">
           <panel-editor-tab editor-tab="ctrl.editorTabs[${i}]" ctrl="ctrl"></panel-editor-tab>
           <panel-editor-tab editor-tab="ctrl.editorTabs[${i}]" ctrl="ctrl"></panel-editor-tab>
         </div>
         </div>
       </div>
       </div>
@@ -119,28 +136,81 @@ export class VisualizationTab extends PureComponent<Props> {
     this.forceUpdate();
     this.forceUpdate();
   };
   };
 
 
-  render() {
+  onOpenVizPicker = () => {
+    this.setState({ isVizPickerOpen: true });
+  };
+
+  onCloseVizPicker = () => {
+    this.setState({ isVizPickerOpen: false });
+  };
+
+  onSearchQueryChange = evt => {
+    const value = evt.target.value;
+    this.setState({
+      searchQuery: value,
+    });
+  };
+
+  renderToolbar = (): JSX.Element => {
     const { plugin } = this.props;
     const { plugin } = this.props;
+    const { searchQuery } = this.state;
+
+    if (this.state.isVizPickerOpen) {
+      return (
+        <>
+          <label className="gf-form--has-input-icon">
+            <input
+              type="text"
+              className="gf-form-input width-13"
+              placeholder=""
+              onChange={this.onSearchQueryChange}
+              value={searchQuery}
+              ref={elem => elem && elem.focus()}
+            />
+            <i className="gf-form-input-icon fa fa-search" />
+          </label>
+          <div className="flex-grow" />
+          <button className="btn btn-link" onClick={this.onCloseVizPicker}>
+            <i className="fa fa-chevron-up" />
+          </button>
+        </>
+      );
+    } else {
+      return (
+        <div className="toolbar__main" onClick={this.onOpenVizPicker}>
+          <img className="toolbar__main-image" src={plugin.info.logos.small} />
+          <div className="toolbar__main-name">{plugin.name}</div>
+          <i className="fa fa-caret-down" />
+        </div>
+      );
+    }
+  };
 
 
-    const panelSelection = {
-      title: plugin.name,
-      imgSrc: plugin.info.logos.small,
-      render: () => {
-        // the needs to be scoped inside this closure
-        const { plugin, onTypeChanged } = this.props;
-        return <VizTypePicker current={plugin} onTypeChanged={onTypeChanged} />;
-      },
-    };
+  onTypeChanged = (plugin: PanelPlugin) => {
+    if (plugin.id === this.props.plugin.id) {
+      this.setState({ isVizPickerOpen: false });
+    } else {
+      this.props.onTypeChanged(plugin);
+    }
+  };
 
 
-    const panelHelp = {
-      title: '',
-      icon: 'fa fa-question',
-      render: () => <h2>Help</h2>,
-    };
+  render() {
+    const { plugin } = this.props;
+    const { isVizPickerOpen, searchQuery } = this.state;
 
 
     return (
     return (
-      <EditorTabBody heading="Visualization" main={panelSelection} toolbarItems={[panelHelp]}>
-        {this.renderPanelOptions()}
+      <EditorTabBody heading="Visualization" renderToolbar={this.renderToolbar}>
+        <>
+          <FadeIn in={isVizPickerOpen} duration={200} unmountOnExit={true}>
+            <VizTypePicker
+              current={plugin}
+              onTypeChanged={this.onTypeChanged}
+              searchQuery={searchQuery}
+              onClose={this.onCloseVizPicker}
+            />
+          </FadeIn>
+          {this.renderPanelOptions()}
+        </>
       </EditorTabBody>
       </EditorTabBody>
     );
     );
   }
   }

+ 10 - 70
public/app/features/dashboard/dashgrid/VizTypePicker.tsx

@@ -4,27 +4,20 @@ import _ from 'lodash';
 import config from 'app/core/config';
 import config from 'app/core/config';
 import { PanelPlugin } from 'app/types/plugins';
 import { PanelPlugin } from 'app/types/plugins';
 import VizTypePickerPlugin from './VizTypePickerPlugin';
 import VizTypePickerPlugin from './VizTypePickerPlugin';
-import KeyboardNavigation, { KeyboardNavigationProps } from './KeyboardNavigation';
 
 
 export interface Props {
 export interface Props {
   current: PanelPlugin;
   current: PanelPlugin;
   onTypeChanged: (newType: PanelPlugin) => void;
   onTypeChanged: (newType: PanelPlugin) => void;
-}
-
-interface State {
   searchQuery: string;
   searchQuery: string;
+  onClose: () => void;
 }
 }
 
 
-export class VizTypePicker extends PureComponent<Props, State> {
+export class VizTypePicker extends PureComponent<Props> {
   searchInput: HTMLElement;
   searchInput: HTMLElement;
   pluginList = this.getPanelPlugins('');
   pluginList = this.getPanelPlugins('');
 
 
   constructor(props) {
   constructor(props) {
     super(props);
     super(props);
-
-    this.state = {
-      searchQuery: '',
-    };
   }
   }
 
 
   get maxSelectedIndex() {
   get maxSelectedIndex() {
@@ -32,12 +25,6 @@ export class VizTypePicker extends PureComponent<Props, State> {
     return filteredPluginList.length - 1;
     return filteredPluginList.length - 1;
   }
   }
 
 
-  componentDidMount() {
-    setTimeout(() => {
-      this.searchInput.focus();
-    }, 300);
-  }
-
   getPanelPlugins(filter): PanelPlugin[] {
   getPanelPlugins(filter): PanelPlugin[] {
     const panels = _.chain(config.panels)
     const panels = _.chain(config.panels)
       .filter({ hideFromList: false })
       .filter({ hideFromList: false })
@@ -48,27 +35,22 @@ export class VizTypePicker extends PureComponent<Props, State> {
     return _.sortBy(panels, 'sort');
     return _.sortBy(panels, 'sort');
   }
   }
 
 
-  renderVizPlugin = (plugin: PanelPlugin, index: number, keyNavProps: KeyboardNavigationProps) => {
+  renderVizPlugin = (plugin: PanelPlugin, index: number) => {
     const { onTypeChanged } = this.props;
     const { onTypeChanged } = this.props;
-    const { selected, onMouseEnter } = keyNavProps;
-    const isSelected = selected === index;
     const isCurrent = plugin.id === this.props.current.id;
     const isCurrent = plugin.id === this.props.current.id;
+
     return (
     return (
       <VizTypePickerPlugin
       <VizTypePickerPlugin
         key={plugin.id}
         key={plugin.id}
-        isSelected={isSelected}
         isCurrent={isCurrent}
         isCurrent={isCurrent}
         plugin={plugin}
         plugin={plugin}
-        onMouseEnter={() => {
-          onMouseEnter(index);
-        }}
         onClick={() => onTypeChanged(plugin)}
         onClick={() => onTypeChanged(plugin)}
       />
       />
     );
     );
   };
   };
 
 
   getFilteredPluginList = (): PanelPlugin[] => {
   getFilteredPluginList = (): PanelPlugin[] => {
-    const { searchQuery } = this.state;
+    const { searchQuery } = this.props;
     const regex = new RegExp(searchQuery, 'i');
     const regex = new RegExp(searchQuery, 'i');
     const pluginList = this.pluginList;
     const pluginList = this.pluginList;
 
 
@@ -79,57 +61,15 @@ export class VizTypePicker extends PureComponent<Props, State> {
     return filtered;
     return filtered;
   };
   };
 
 
-  onSearchQueryChange = evt => {
-    const value = evt.target.value;
-    this.setState(prevState => ({
-      ...prevState,
-      searchQuery: value,
-    }));
-  };
-
-  renderFilters = ({ onKeyDown, selected }: KeyboardNavigationProps) => {
-    const { searchQuery } = this.state;
-    return (
-      <>
-        <label className="gf-form--has-input-icon">
-          <input
-            type="text"
-            className="gf-form-input width-13"
-            placeholder=""
-            ref={elem => (this.searchInput = elem)}
-            onChange={this.onSearchQueryChange}
-            value={searchQuery}
-            onKeyDown={evt => {
-              onKeyDown(evt, this.maxSelectedIndex, () => {
-                const { onTypeChanged } = this.props;
-                const vizType = this.getFilteredPluginList()[selected];
-                onTypeChanged(vizType);
-              });
-            }}
-          />
-          <i className="gf-form-input-icon fa fa-search" />
-        </label>
-      </>
-    );
-  };
-
   render() {
   render() {
     const filteredPluginList = this.getFilteredPluginList();
     const filteredPluginList = this.getFilteredPluginList();
 
 
     return (
     return (
-      <KeyboardNavigation
-        render={(keyNavProps: KeyboardNavigationProps) => (
-          <>
-            <div className="cta-form__bar">
-              {this.renderFilters(keyNavProps)}
-              <div className="gf-form--grow" />
-            </div>
-            <div className="viz-picker">
-              {filteredPluginList.map((plugin, index) => this.renderVizPlugin(plugin, index, keyNavProps))}
-            </div>
-          </>
-        )}
-      />
+      <div className="viz-picker">
+        <div className="viz-picker-list">
+          {filteredPluginList.map((plugin, index) => this.renderVizPlugin(plugin, index))}
+        </div>
+      </div>
     );
     );
   }
   }
 }
 }

+ 4 - 7
public/app/features/dashboard/dashgrid/VizTypePickerPlugin.tsx

@@ -1,32 +1,29 @@
-import React from 'react';
+import React from 'react';
 import classNames from 'classnames';
 import classNames from 'classnames';
 import { PanelPlugin } from 'app/types/plugins';
 import { PanelPlugin } from 'app/types/plugins';
 
 
 interface Props {
 interface Props {
-  isSelected: boolean;
   isCurrent: boolean;
   isCurrent: boolean;
   plugin: PanelPlugin;
   plugin: PanelPlugin;
   onClick: () => void;
   onClick: () => void;
-  onMouseEnter: () => void;
 }
 }
 
 
 const VizTypePickerPlugin = React.memo(
 const VizTypePickerPlugin = React.memo(
-  ({ isSelected, isCurrent, plugin, onClick, onMouseEnter }: Props) => {
+  ({ isCurrent, plugin, onClick }: Props) => {
     const cssClass = classNames({
     const cssClass = classNames({
       'viz-picker__item': true,
       'viz-picker__item': true,
-      'viz-picker__item--selected': isSelected,
       'viz-picker__item--current': isCurrent,
       'viz-picker__item--current': isCurrent,
     });
     });
 
 
     return (
     return (
-      <div className={cssClass} onClick={onClick} title={plugin.name} onMouseEnter={onMouseEnter}>
+      <div className={cssClass} onClick={onClick} title={plugin.name}>
         <div className="viz-picker__item-name">{plugin.name}</div>
         <div className="viz-picker__item-name">{plugin.name}</div>
         <img className="viz-picker__item-img" src={plugin.info.logos.small} />
         <img className="viz-picker__item-img" src={plugin.info.logos.small} />
       </div>
       </div>
     );
     );
   },
   },
   (prevProps, nextProps) => {
   (prevProps, nextProps) => {
-    if (prevProps.isSelected === nextProps.isSelected && prevProps.isCurrent === nextProps.isCurrent) {
+    if (prevProps.isCurrent === nextProps.isCurrent) {
       return true;
       return true;
     }
     }
     return false;
     return false;

+ 19 - 0
public/app/features/dashboard/panel_model.ts

@@ -1,6 +1,7 @@
 import { Emitter } from 'app/core/utils/emitter';
 import { Emitter } from 'app/core/utils/emitter';
 import _ from 'lodash';
 import _ from 'lodash';
 import { PANEL_OPTIONS_KEY_PREFIX } from 'app/core/constants';
 import { PANEL_OPTIONS_KEY_PREFIX } from 'app/core/constants';
+import { DataQuery } from 'app/types';
 
 
 export interface GridPos {
 export interface GridPos {
   x: number;
   x: number;
@@ -237,6 +238,24 @@ export class PanelModel {
     this.restorePanelOptions(pluginId);
     this.restorePanelOptions(pluginId);
   }
   }
 
 
+  addQuery(query?: Partial<DataQuery>) {
+    query = query || { refId: 'A' };
+    query.refId = this.getNextQueryLetter();
+    query.isNew = true;
+
+    this.targets.push(query);
+  }
+
+  getNextQueryLetter(): string {
+    const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
+
+    return _.find(letters, refId => {
+      return _.every(this.targets, other => {
+        return other.refId !== refId;
+      });
+    });
+  }
+
   destroy() {
   destroy() {
     this.events.emit('panel-teardown');
     this.events.emit('panel-teardown');
     this.events.removeAllListeners();
     this.events.removeAllListeners();

+ 9 - 167
public/app/features/panel/metrics_tab.ts

@@ -1,181 +1,24 @@
 // Libraries
 // Libraries
 import _ from 'lodash';
 import _ from 'lodash';
-import Remarkable from 'remarkable';
 
 
 // Services & utils
 // Services & utils
 import coreModule from 'app/core/core_module';
 import coreModule from 'app/core/core_module';
-import config from 'app/core/config';
 import { Emitter } from 'app/core/utils/emitter';
 import { Emitter } from 'app/core/utils/emitter';
 
 
 // Types
 // Types
 import { DashboardModel } from '../dashboard/dashboard_model';
 import { DashboardModel } from '../dashboard/dashboard_model';
+import { PanelModel } from '../dashboard/panel_model';
+import { DataQuery } from 'app/types';
 
 
-export class MetricsTabCtrl {
-  dsName: string;
-  panel: any;
-  panelCtrl: any;
-  datasources: any[];
-  datasourceInstance: any;
-  nextRefId: string;
+export interface AngularQueryComponentScope {
+  panel: PanelModel;
   dashboard: DashboardModel;
   dashboard: DashboardModel;
-  panelDsValue: any;
-  addQueryDropdown: any;
-  queryTroubleshooterOpen: boolean;
-  helpOpen: boolean;
-  optionsOpen: boolean;
-  hasQueryHelp: boolean;
-  helpHtml: string;
-  queryOptions: any;
   events: Emitter;
   events: Emitter;
-
-  /** @ngInject */
-  constructor($scope, private $sce, datasourceSrv, private backendSrv) {
-    this.panelCtrl = $scope.ctrl;
-    $scope.ctrl = this;
-
-    this.panel = this.panelCtrl.panel;
-    this.panel.datasource = this.panel.datasource || null;
-    this.panel.targets = this.panel.targets || [{}];
-
-    this.dashboard = this.panelCtrl.dashboard;
-    this.datasources = datasourceSrv.getMetricSources();
-    this.panelDsValue = this.panelCtrl.panel.datasource;
-
-    // added here as old query controller expects this on panelCtrl but
-    // they are getting MetricsTabCtrl instead
-    this.events = this.panel.events;
-
-    for (const ds of this.datasources) {
-      if (ds.value === this.panelDsValue) {
-        this.datasourceInstance = ds;
-      }
-    }
-
-    this.addQueryDropdown = { text: 'Add Query', value: null, fake: true };
-
-    // update next ref id
-    this.nextRefId = this.dashboard.getNextQueryLetter(this.panel);
-    this.updateDatasourceOptions();
-  }
-
-  updateDatasourceOptions() {
-    if (this.datasourceInstance) {
-      this.hasQueryHelp = this.datasourceInstance.meta.hasQueryHelp;
-      this.queryOptions = this.datasourceInstance.meta.queryOptions;
-    }
-  }
-
-  getOptions(includeBuiltin) {
-    return Promise.resolve(
-      this.datasources
-        .filter(value => {
-          return includeBuiltin || !value.meta.builtIn;
-        })
-        .map(ds => {
-          return { value: ds.value, text: ds.name, datasource: ds };
-        })
-    );
-  }
-
-  datasourceChanged(option) {
-    if (!option) {
-      return;
-    }
-
-    this.setDatasource(option.datasource);
-    this.updateDatasourceOptions();
-  }
-
-  setDatasource(datasource) {
-    // switching to mixed
-    if (datasource.meta.mixed) {
-      _.each(this.panel.targets, target => {
-        target.datasource = this.panel.datasource;
-        if (!target.datasource) {
-          target.datasource = config.defaultDatasource;
-        }
-      });
-    } else if (this.datasourceInstance) {
-      // if switching from mixed
-      if (this.datasourceInstance.meta.mixed) {
-        _.each(this.panel.targets, target => {
-          delete target.datasource;
-        });
-      } else if (this.datasourceInstance.meta.id !== datasource.meta.id) {
-        // we are changing data source type, clear queries
-        this.panel.targets = [{ refId: 'A' }];
-        this.panelCtrl.nextRefId = this.dashboard.getNextQueryLetter(this.panel);
-      }
-    }
-
-    this.datasourceInstance = datasource;
-    this.panel.datasource = datasource.value;
-    this.panel.refresh();
-  }
-
-  addMixedQuery(option) {
-    if (!option) {
-      return;
-    }
-
-    this.panelCtrl.addQuery({
-      isNew: true,
-      datasource: option.datasource.name,
-    });
-    this.addQueryDropdown = { text: 'Add Query', value: null, fake: true };
-  }
-
-  toggleHelp() {
-    this.optionsOpen = false;
-    this.queryTroubleshooterOpen = false;
-    this.helpOpen = !this.helpOpen;
-
-    this.backendSrv.get(`/api/plugins/${this.datasourceInstance.meta.id}/markdown/query_help`).then(res => {
-      const md = new Remarkable();
-      this.helpHtml = this.$sce.trustAsHtml(md.render(res));
-    });
-  }
-
-  toggleOptions() {
-    this.helpOpen = false;
-    this.queryTroubleshooterOpen = false;
-    this.optionsOpen = !this.optionsOpen;
-  }
-
-  toggleQueryTroubleshooter() {
-    this.helpOpen = false;
-    this.optionsOpen = false;
-    this.queryTroubleshooterOpen = !this.queryTroubleshooterOpen;
-  }
-
-  addQuery(query?) {
-    query = query || {};
-    query.refId = this.dashboard.getNextQueryLetter(this.panel);
-    query.isNew = true;
-
-    this.panel.targets.push(query);
-    this.nextRefId = this.dashboard.getNextQueryLetter(this.panel);
-  }
-
-  refresh() {
-    this.panel.refresh();
-  }
-
-  render() {
-    this.panel.render();
-  }
-
-  removeQuery(target) {
-    const index = _.indexOf(this.panel.targets, target);
-    this.panel.targets.splice(index, 1);
-    this.nextRefId = this.dashboard.getNextQueryLetter(this.panel);
-    this.panel.refresh();
-  }
-
-  moveQuery(target, direction) {
-    const index = _.indexOf(this.panel.targets, target);
-    _.move(this.panel.targets, index, index + direction);
-  }
+  refresh: () => void;
+  render: () => void;
+  removeQuery: (query: DataQuery) => void;
+  addQuery: (query?: DataQuery) => void;
+  moveQuery: (query: DataQuery, direction: number) => void;
 }
 }
 
 
 /** @ngInject */
 /** @ngInject */
@@ -185,7 +28,6 @@ export function metricsTabDirective() {
     restrict: 'E',
     restrict: 'E',
     scope: true,
     scope: true,
     templateUrl: 'public/app/features/panel/partials/metrics_tab.html',
     templateUrl: 'public/app/features/panel/partials/metrics_tab.html',
-    controller: MetricsTabCtrl,
   };
   };
 }
 }
 
 

+ 17 - 20
public/app/features/panel/partials/metrics_tab.html

@@ -1,5 +1,3 @@
-
-<div class="query-editor-rows gf-form-group" ng-if="ctrl.datasourceInstance">
 	<div ng-repeat="target in ctrl.panel.targets" ng-class="{'gf-form-disabled': target.hide}">
 	<div ng-repeat="target in ctrl.panel.targets" ng-class="{'gf-form-disabled': target.hide}">
 		<rebuild-on-change property="ctrl.panel.datasource || target.datasource" show-null="true">
 		<rebuild-on-change property="ctrl.panel.datasource || target.datasource" show-null="true">
 			<plugin-component type="query-ctrl">
 			<plugin-component type="query-ctrl">
@@ -7,21 +5,20 @@
 		</rebuild-on-change>
 		</rebuild-on-change>
 	</div>
 	</div>
 
 
-	<div class="gf-form-query">
-		<div class="gf-form gf-form-query-letter-cell">
-			<label class="gf-form-label">
-				<span class="gf-form-query-letter-cell-carret">
-					<i class="fa fa-caret-down"></i>
-				</span>
-				<span class="gf-form-query-letter-cell-letter">{{ctrl.nextRefId}}</span>
-			</label>
-			<button class="btn btn-secondary gf-form-btn" ng-click="ctrl.addQuery()" ng-hide="ctrl.datasourceInstance.meta.mixed">
-				Add Query
-			</button>
-			<div class="dropdown" ng-if="ctrl.datasourceInstance.meta.mixed">
-				<gf-form-dropdown model="ctrl.addQueryDropdown" get-options="ctrl.getOptions(false)" on-change="ctrl.addMixedQuery($option)">
-				</gf-form-dropdown>
-			</div>
-		</div>
-	</div>
-</div>
+	<!-- <div class="gf&#45;form&#45;query"> -->
+	<!-- 	<div class="gf&#45;form gf&#45;form&#45;query&#45;letter&#45;cell"> -->
+	<!-- 		<label class="gf&#45;form&#45;label"> -->
+	<!-- 			<span class="gf&#45;form&#45;query&#45;letter&#45;cell&#45;carret"> -->
+	<!-- 				<i class="fa fa&#45;caret&#45;down"></i> -->
+	<!-- 			</span> -->
+	<!-- 			<span class="gf&#45;form&#45;query&#45;letter&#45;cell&#45;letter">{{ctrl.nextRefId}}</span> -->
+	<!-- 		</label> -->
+	<!-- 		<button class="btn btn&#45;secondary gf&#45;form&#45;btn" ng&#45;click="ctrl.addQuery()" ng&#45;hide="ctrl.datasourceInstance.meta.mixed"> -->
+	<!-- 			Add Query -->
+	<!-- 		</button> -->
+	<!-- 		<div class="dropdown" ng&#45;if="ctrl.datasourceInstance.meta.mixed"> -->
+	<!-- 			<gf&#45;form&#45;dropdown model="ctrl.addQueryDropdown" get&#45;options="ctrl.getOptions(false)" on&#45;change="ctrl.addMixedQuery($option)"> -->
+	<!-- 			</gf&#45;form&#45;dropdown> -->
+	<!-- 		</div> -->
+	<!-- 	</div> -->
+	<!-- </div> -->

+ 1 - 1
public/app/features/panel/query_editor_row.ts

@@ -20,7 +20,7 @@ export class QueryRowCtrl {
     this.hideEditorRowActions = this.panelCtrl.hideEditorRowActions;
     this.hideEditorRowActions = this.panelCtrl.hideEditorRowActions;
 
 
     if (!this.target.refId) {
     if (!this.target.refId) {
-      this.target.refId = this.panelCtrl.dashboard.getNextQueryLetter(this.panel);
+      this.target.refId = this.panel.getNextQueryLetter();
     }
     }
 
 
     this.toggleCollapse(true);
     this.toggleCollapse(true);

+ 2 - 2
public/app/plugins/datasource/prometheus/partials/query.editor.html

@@ -8,7 +8,7 @@
   </div>
   </div>
 
 
   <div class="gf-form-inline">
   <div class="gf-form-inline">
-    <div class="gf-form max-width-26">
+    <div class="gf-form">
       <label class="gf-form-label width-8">Legend format</label>
       <label class="gf-form-label width-8">Legend format</label>
       <input type="text" class="gf-form-input" ng-model="ctrl.target.legendFormat" spellcheck='false' placeholder="legend format"
       <input type="text" class="gf-form-input" ng-model="ctrl.target.legendFormat" spellcheck='false' placeholder="legend format"
         data-min-length=0 data-items=1000 ng-model-onblur ng-change="ctrl.refreshMetricData()">
         data-min-length=0 data-items=1000 ng-model-onblur ng-change="ctrl.refreshMetricData()">
@@ -58,4 +58,4 @@
       <div class="gf-form-label gf-form-label--grow"></div>
       <div class="gf-form-label gf-form-label--grow"></div>
     </div>
     </div>
   </div>
   </div>
-</query-editor-row>
+</query-editor-row>

+ 155 - 0
public/app/plugins/panel/gauge/MappingRow.tsx

@@ -0,0 +1,155 @@
+import React, { PureComponent } from 'react';
+import { Label } from 'app/core/components/Label/Label';
+import SimplePicker from 'app/core/components/Picker/SimplePicker';
+import { MappingType, RangeMap, ValueMap } from 'app/types';
+
+interface Props {
+  mapping: ValueMap | RangeMap;
+  updateMapping: (mapping) => void;
+  removeMapping: () => void;
+}
+
+interface State {
+  from: string;
+  id: number;
+  operator: string;
+  text: string;
+  to: string;
+  type: MappingType;
+  value: string;
+}
+
+const mappingOptions = [
+  { value: MappingType.ValueToText, label: 'Value' },
+  { value: MappingType.RangeToText, label: 'Range' },
+];
+
+export default class MappingRow extends PureComponent<Props, State> {
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      ...props.mapping,
+    };
+  }
+
+  onMappingValueChange = event => {
+    this.setState({ value: event.target.value });
+  };
+
+  onMappingFromChange = event => {
+    this.setState({ from: event.target.value });
+  };
+
+  onMappingToChange = event => {
+    this.setState({ to: event.target.value });
+  };
+
+  onMappingTextChange = event => {
+    this.setState({ text: event.target.value });
+  };
+
+  onMappingTypeChange = mappingType => {
+    this.setState({ type: mappingType });
+  };
+
+  updateMapping = () => {
+    this.props.updateMapping({ ...this.state });
+  };
+
+  renderRow() {
+    const { from, text, to, type, value } = this.state;
+
+    if (type === MappingType.RangeToText) {
+      return (
+        <div className="gf-form">
+          <div className="gf-form-inline mapping-row-input">
+            <Label width={4}>From</Label>
+            <div>
+              <input
+                className="gf-form-input"
+                value={from}
+                onBlur={this.updateMapping}
+                onChange={this.onMappingFromChange}
+              />
+            </div>
+          </div>
+          <div className="gf-form-inline mapping-row-input">
+            <Label width={4}>To</Label>
+            <div>
+              <input
+                className="gf-form-input"
+                value={to}
+                onBlur={this.updateMapping}
+                onChange={this.onMappingToChange}
+              />
+            </div>
+          </div>
+          <div className="gf-form-inline mapping-row-input">
+            <Label width={4}>Text</Label>
+            <div>
+              <input
+                className="gf-form-input"
+                value={text}
+                onBlur={this.updateMapping}
+                onChange={this.onMappingTextChange}
+              />
+            </div>
+          </div>
+        </div>
+      );
+    }
+
+    return (
+      <div className="gf-form">
+        <div className="gf-form-inline mapping-row-input">
+          <Label width={4}>Value</Label>
+          <div>
+            <input
+              className="gf-form-input"
+              onBlur={this.updateMapping}
+              onChange={this.onMappingValueChange}
+              value={value}
+            />
+          </div>
+        </div>
+        <div className="gf-form-inline mapping-row-input">
+          <Label width={4}>Text</Label>
+          <div>
+            <input
+              className="gf-form-input"
+              onBlur={this.updateMapping}
+              value={text}
+              onChange={this.onMappingTextChange}
+            />
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+  render() {
+    const { type } = this.state;
+
+    return (
+      <div className="mapping-row">
+        <div className="gf-form-inline mapping-row-type">
+          <Label width={5}>Type</Label>
+          <SimplePicker
+            placeholder="Choose type"
+            options={mappingOptions}
+            value={mappingOptions.find(o => o.value === type)}
+            getOptionLabel={i => i.label}
+            getOptionValue={i => i.value}
+            onSelected={type => this.onMappingTypeChange(type.value)}
+            width={7}
+          />
+        </div>
+        <div>{this.renderRow()}</div>
+        <div onClick={this.props.removeMapping} className="threshold-row-remove">
+          <i className="fa fa-times" />
+        </div>
+      </div>
+    );
+  }
+}

+ 23 - 16
public/app/plugins/panel/gauge/Threshold.test.tsx

@@ -1,13 +1,19 @@
 import React from 'react';
 import React from 'react';
 import { shallow } from 'enzyme';
 import { shallow } from 'enzyme';
-import Thresholds, { BasicGaugeColor } from './Thresholds';
-import { OptionsProps } from './module';
+import Thresholds from './Thresholds';
+import { defaultProps, OptionsProps } from './module';
 import { PanelOptionsProps } from '../../../types';
 import { PanelOptionsProps } from '../../../types';
 
 
 const setup = (propOverrides?: object) => {
 const setup = (propOverrides?: object) => {
   const props: PanelOptionsProps<OptionsProps> = {
   const props: PanelOptionsProps<OptionsProps> = {
     onChange: jest.fn(),
     onChange: jest.fn(),
-    options: {} as OptionsProps,
+    options: {
+      ...defaultProps.options,
+      thresholds: [
+        { index: 0, label: 'Min', value: 0, canRemove: false, color: 'rgba(50, 172, 45, 0.97)' },
+        { index: 1, label: 'Max', value: 100, canRemove: false },
+      ],
+    },
   };
   };
 
 
   Object.assign(props, propOverrides);
   Object.assign(props, propOverrides);
@@ -15,12 +21,6 @@ const setup = (propOverrides?: object) => {
   return shallow(<Thresholds {...props} />).instance() as Thresholds;
   return shallow(<Thresholds {...props} />).instance() as Thresholds;
 };
 };
 
 
-const thresholds = [
-  { index: 0, label: 'Min', value: 0, canRemove: false, color: BasicGaugeColor.Green },
-  { index: 1, label: '', value: 50, canRemove: true, color: 'rgba(237, 129, 40, 0.89)' },
-  { index: 2, label: 'Max', value: 100, canRemove: false, color: BasicGaugeColor.Red },
-];
-
 describe('Add threshold', () => {
 describe('Add threshold', () => {
   it('should add threshold between min and max', () => {
   it('should add threshold between min and max', () => {
     const instance = setup();
     const instance = setup();
@@ -28,24 +28,31 @@ describe('Add threshold', () => {
     instance.onAddThreshold(1);
     instance.onAddThreshold(1);
 
 
     expect(instance.state.thresholds).toEqual([
     expect(instance.state.thresholds).toEqual([
-      { index: 0, label: 'Min', value: 0, canRemove: false, color: BasicGaugeColor.Green },
-      { index: 1, label: '', value: 50, canRemove: true, color: 'rgba(131, 123, 52, 0.99)' },
-      { index: 2, label: 'Max', value: 100, canRemove: false, color: BasicGaugeColor.Red },
+      { index: 0, label: 'Min', value: 0, canRemove: false, color: 'rgba(50, 172, 45, 0.97)' },
+      { index: 1, label: '', value: 50, canRemove: true, color: 'rgba(237, 129, 40, 0.89)' },
+      { index: 2, label: 'Max', value: 100, canRemove: false },
     ]);
     ]);
   });
   });
 
 
   it('should add threshold between min and added threshold', () => {
   it('should add threshold between min and added threshold', () => {
     const instance = setup({
     const instance = setup({
-      options: { thresholds: thresholds },
+      options: {
+        ...defaultProps.options,
+        thresholds: [
+          { index: 0, label: 'Min', value: 0, canRemove: false, color: 'rgba(50, 172, 45, 0.97)' },
+          { index: 1, label: '', value: 50, canRemove: true, color: 'rgba(237, 129, 40, 0.89)' },
+          { index: 2, label: 'Max', value: 100, canRemove: false },
+        ],
+      },
     });
     });
 
 
     instance.onAddThreshold(1);
     instance.onAddThreshold(1);
 
 
     expect(instance.state.thresholds).toEqual([
     expect(instance.state.thresholds).toEqual([
-      { index: 0, label: 'Min', value: 0, canRemove: false, color: BasicGaugeColor.Green },
-      { index: 1, label: '', value: 25, canRemove: true, color: 'rgba(144, 151, 43, 0.93)' },
+      { index: 0, label: 'Min', value: 0, canRemove: false, color: 'rgba(50, 172, 45, 0.97)' },
+      { index: 1, label: '', value: 25, canRemove: true, color: 'rgba(237, 129, 40, 0.89)' },
       { index: 2, label: '', value: 50, canRemove: true, color: 'rgba(237, 129, 40, 0.89)' },
       { index: 2, label: '', value: 50, canRemove: true, color: 'rgba(237, 129, 40, 0.89)' },
-      { index: 3, label: 'Max', value: 100, canRemove: false, color: BasicGaugeColor.Red },
+      { index: 3, label: 'Max', value: 100, canRemove: false },
     ]);
     ]);
   });
   });
 });
 });

+ 2 - 11
public/app/plugins/panel/gauge/Thresholds.tsx

@@ -3,27 +3,18 @@ import classNames from 'classnames/bind';
 import tinycolor from 'tinycolor2';
 import tinycolor from 'tinycolor2';
 import { ColorPicker } from 'app/core/components/colorpicker/ColorPicker';
 import { ColorPicker } from 'app/core/components/colorpicker/ColorPicker';
 import { OptionModuleProps } from './module';
 import { OptionModuleProps } from './module';
-import { Threshold } from 'app/types';
+import { BasicGaugeColor, Threshold } from 'app/types';
 
 
 interface State {
 interface State {
   thresholds: Threshold[];
   thresholds: Threshold[];
 }
 }
 
 
-export enum BasicGaugeColor {
-  Green = 'rgba(50, 172, 45, 0.97)',
-  Orange = 'rgba(237, 129, 40, 0.89)',
-  Red = 'rgb(212, 74, 58)',
-}
-
 export default class Thresholds extends PureComponent<OptionModuleProps, State> {
 export default class Thresholds extends PureComponent<OptionModuleProps, State> {
   constructor(props) {
   constructor(props) {
     super(props);
     super(props);
 
 
     this.state = {
     this.state = {
-      thresholds: this.props.options.thresholds || [
-        { index: 0, label: 'Min', value: 0, canRemove: false, color: BasicGaugeColor.Green },
-        { index: 1, label: 'Max', value: 100, canRemove: false, color: BasicGaugeColor.Red },
-      ],
+      thresholds: props.options.thresholds,
     };
     };
   }
   }
 
 

+ 73 - 0
public/app/plugins/panel/gauge/ValueMappings.test.tsx

@@ -0,0 +1,73 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import ValueMappings from './ValueMappings';
+import { defaultProps, OptionModuleProps } from './module';
+import { MappingType } from 'app/types';
+
+const setup = (propOverrides?: object) => {
+  const props: OptionModuleProps = {
+    onChange: jest.fn(),
+    options: {
+      ...defaultProps.options,
+      mappings: [
+        { id: 1, operator: '', type: MappingType.ValueToText, value: '20', text: 'Ok' },
+        { id: 2, operator: '', type: MappingType.RangeToText, from: '21', to: '30', text: 'Meh' },
+      ],
+    },
+  };
+
+  Object.assign(props, propOverrides);
+
+  const wrapper = shallow(<ValueMappings {...props} />);
+
+  const instance = wrapper.instance() as ValueMappings;
+
+  return {
+    instance,
+    wrapper,
+  };
+};
+
+describe('Render', () => {
+  it('should render component', () => {
+    const { wrapper } = setup();
+
+    expect(wrapper).toMatchSnapshot();
+  });
+});
+
+describe('On remove mapping', () => {
+  it('Should remove mapping with id 0', () => {
+    const { instance } = setup();
+    instance.onRemoveMapping(1);
+
+    expect(instance.state.mappings).toEqual([
+      { id: 2, operator: '', type: MappingType.RangeToText, from: '21', to: '30', text: 'Meh' },
+    ]);
+  });
+
+  it('should remove mapping with id 1', () => {
+    const { instance } = setup();
+    instance.onRemoveMapping(2);
+
+    expect(instance.state.mappings).toEqual([
+      { id: 1, operator: '', type: MappingType.ValueToText, value: '20', text: 'Ok' },
+    ]);
+  });
+});
+
+describe('Next id to add', () => {
+  it('should be 4', () => {
+    const { instance } = setup();
+
+    instance.addMapping();
+
+    expect(instance.state.nextIdToAdd).toEqual(4);
+  });
+
+  it('should default to 1', () => {
+    const { instance } = setup({ options: { ...defaultProps.options } });
+
+    expect(instance.state.nextIdToAdd).toEqual(1);
+  });
+});

+ 100 - 0
public/app/plugins/panel/gauge/ValueMappings.tsx

@@ -0,0 +1,100 @@
+import React, { PureComponent } from 'react';
+import MappingRow from './MappingRow';
+import { OptionModuleProps } from './module';
+import { MappingType, RangeMap, ValueMap } from 'app/types';
+
+interface State {
+  mappings: Array<ValueMap | RangeMap>;
+  nextIdToAdd: number;
+}
+
+export default class ValueMappings extends PureComponent<OptionModuleProps, State> {
+  constructor(props) {
+    super(props);
+
+    const mappings = props.options.mappings;
+
+    this.state = {
+      mappings: mappings || [],
+      nextIdToAdd: mappings.length > 0 ? this.getMaxIdFromMappings(mappings) : 1,
+    };
+  }
+
+  getMaxIdFromMappings(mappings) {
+    return Math.max.apply(null, mappings.map(mapping => mapping.id).map(m => m)) + 1;
+  }
+
+  addMapping = () =>
+    this.setState(prevState => ({
+      mappings: [
+        ...prevState.mappings,
+        {
+          id: prevState.nextIdToAdd,
+          operator: '',
+          value: '',
+          text: '',
+          type: MappingType.ValueToText,
+          from: '',
+          to: '',
+        },
+      ],
+      nextIdToAdd: prevState.nextIdToAdd + 1,
+    }));
+
+  onRemoveMapping = id => {
+    this.setState(
+      prevState => ({
+        mappings: prevState.mappings.filter(m => {
+          return m.id !== id;
+        }),
+      }),
+      () => {
+        this.props.onChange({ ...this.props.options, mappings: this.state.mappings });
+      }
+    );
+  };
+
+  updateGauge = mapping => {
+    this.setState(
+      prevState => ({
+        mappings: prevState.mappings.map(m => {
+          if (m.id === mapping.id) {
+            return { ...mapping };
+          }
+
+          return m;
+        }),
+      }),
+      () => {
+        this.props.onChange({ ...this.props.options, mappings: this.state.mappings });
+      }
+    );
+  };
+
+  render() {
+    const { mappings } = this.state;
+
+    return (
+      <div className="section gf-form-group">
+        <h5 className="page-heading">Value mappings</h5>
+        <div>
+          {mappings.length > 0 &&
+            mappings.map((mapping, index) => (
+              <MappingRow
+                key={`${mapping.text}-${index}`}
+                mapping={mapping}
+                updateMapping={this.updateGauge}
+                removeMapping={() => this.onRemoveMapping(mapping.id)}
+              />
+            ))}
+        </div>
+        <div className="add-mapping-row" onClick={this.addMapping}>
+          <div className="add-mapping-row-icon">
+            <i className="fa fa-plus" />
+          </div>
+          <div className="add-mapping-row-label">Add mapping</div>
+        </div>
+      </div>
+    );
+  }
+}

+ 61 - 0
public/app/plugins/panel/gauge/__snapshots__/ValueMappings.test.tsx.snap

@@ -0,0 +1,61 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Render should render component 1`] = `
+<div
+  className="section gf-form-group"
+>
+  <h5
+    className="page-heading"
+  >
+    Value mappings
+  </h5>
+  <div>
+    <MappingRow
+      key="Ok-0"
+      mapping={
+        Object {
+          "id": 1,
+          "operator": "",
+          "text": "Ok",
+          "type": 1,
+          "value": "20",
+        }
+      }
+      removeMapping={[Function]}
+      updateMapping={[Function]}
+    />
+    <MappingRow
+      key="Meh-1"
+      mapping={
+        Object {
+          "from": "21",
+          "id": 2,
+          "operator": "",
+          "text": "Meh",
+          "to": "30",
+          "type": 2,
+        }
+      }
+      removeMapping={[Function]}
+      updateMapping={[Function]}
+    />
+  </div>
+  <div
+    className="add-mapping-row"
+    onClick={[Function]}
+  >
+    <div
+      className="add-mapping-row-icon"
+    >
+      <i
+        className="fa fa-plus"
+      />
+    </div>
+    <div
+      className="add-mapping-row-label"
+    >
+      Add mapping
+    </div>
+  </div>
+</div>
+`;

+ 28 - 4
public/app/plugins/panel/gauge/module.tsx

@@ -1,10 +1,19 @@
 import React, { PureComponent } from 'react';
 import React, { PureComponent } from 'react';
 import Gauge from 'app/viz/Gauge';
 import Gauge from 'app/viz/Gauge';
-import { NullValueMode, PanelOptionsProps, PanelProps, Threshold } from 'app/types';
 import { getTimeSeriesVMs } from 'app/viz/state/timeSeries';
 import { getTimeSeriesVMs } from 'app/viz/state/timeSeries';
 import ValueOptions from './ValueOptions';
 import ValueOptions from './ValueOptions';
 import GaugeOptions from './GaugeOptions';
 import GaugeOptions from './GaugeOptions';
 import Thresholds from './Thresholds';
 import Thresholds from './Thresholds';
+import ValueMappings from './ValueMappings';
+import {
+  BasicGaugeColor,
+  NullValueMode,
+  PanelOptionsProps,
+  PanelProps,
+  RangeMap,
+  Threshold,
+  ValueMap,
+} from 'app/types';
 
 
 export interface OptionsProps {
 export interface OptionsProps {
   decimals: number;
   decimals: number;
@@ -15,6 +24,7 @@ export interface OptionsProps {
   suffix: string;
   suffix: string;
   unit: string;
   unit: string;
   thresholds: Threshold[];
   thresholds: Threshold[];
+  mappings: Array<RangeMap | ValueMap>;
 }
 }
 
 
 export interface OptionModuleProps {
 export interface OptionModuleProps {
@@ -30,6 +40,14 @@ export const defaultProps = {
     showThresholdMarkers: true,
     showThresholdMarkers: true,
     showThresholdLabels: false,
     showThresholdLabels: false,
     suffix: '',
     suffix: '',
+    decimals: 0,
+    stat: '',
+    unit: '',
+    mappings: [],
+    thresholds: [
+      { index: 0, label: 'Min', value: 0, canRemove: false, color: BasicGaugeColor.Green },
+      { index: 1, label: 'Max', value: 100, canRemove: false },
+    ],
   },
   },
 };
 };
 
 
@@ -52,11 +70,17 @@ class Options extends PureComponent<PanelOptionsProps<OptionsProps>> {
   static defaultProps = defaultProps;
   static defaultProps = defaultProps;
 
 
   render() {
   render() {
+    const { onChange, options } = this.props;
     return (
     return (
       <div>
       <div>
-        <ValueOptions onChange={this.props.onChange} options={this.props.options} />
-        <GaugeOptions onChange={this.props.onChange} options={this.props.options} />
-        <Thresholds onChange={this.props.onChange} options={this.props.options} />
+        <div className="form-section">
+          <ValueOptions onChange={onChange} options={options} />
+          <GaugeOptions onChange={onChange} options={options} />
+          <Thresholds onChange={onChange} options={options} />
+        </div>
+        <div className="form-section">
+          <ValueMappings onChange={onChange} options={options} />
+        </div>
       </div>
       </div>
     );
     );
   }
   }

+ 153 - 163
public/app/plugins/panel/table/column_options.html

@@ -1,200 +1,190 @@
-<div class="edit-tab-with-sidemenu">
-  <aside class="edit-sidemenu-aside">
-    <ul class="edit-sidemenu">
-      <li ng-repeat="style in editor.panel.styles" ng-class="{active: editor.activeStyleIndex === $index}">
-        <a ng-click="editor.activeStyleIndex = $index">{{style.pattern || 'New rule'}}</a>
-      </li>
-      <li>
-        <a class="pointer" ng-click="editor.addColumnStyle()">
-          <i class="fa fa-plus"></i>&nbsp;Add
-        </a>
-      </li>
-    </ul>
-  </aside>
-
-  <div class="edit-tab-content" ng-repeat="style in editor.panel.styles" ng-if="editor.activeStyleIndex === $index">
-
-    <div class="section gf-form-group">
-      <h5 class="section-heading">Options</h5>
-      <div class="gf-form-inline">
-        <div class="gf-form">
-          <label class="gf-form-label width-13">Apply to columns named</label>
-          <input type="text" placeholder="Name or regex" class="gf-form-input width-13" ng-model="style.pattern" bs-tooltip="'Specify regex using /my.*regex/ syntax'"
+<div class="edit-tab-content" ng-repeat="style in editor.panel.styles">
+  <p class="column-styles-heading">{{style.pattern || 'New rule'}}</p>
+  <div class="section gf-form-group">
+    <h5 class="section-heading">Options</h5>
+    <div class="gf-form-inline">
+      <div class="gf-form">
+        <label class="gf-form-label width-13">Apply to columns named</label>
+        <input type="text" placeholder="Name or regex" class="gf-form-input width-13" ng-model="style.pattern" bs-tooltip="'Specify regex using /my.*regex/ syntax'"
             bs-typeahead="editor.getColumnNames" ng-blur="editor.render()" data-min-length=0 data-items=100 ng-model-onblur
             bs-typeahead="editor.getColumnNames" ng-blur="editor.render()" data-min-length=0 data-items=100 ng-model-onblur
             data-placement="right">
             data-placement="right">
-        </div>
-      </div>
-      <div class="gf-form" ng-if="style.type !== 'hidden'">
-        <label class="gf-form-label width-13">Column Header</label>
-        <input type="text" class="gf-form-input width-13" ng-model="style.alias" ng-change="editor.render()" ng-model-onblur placeholder="Override header label">
       </div>
       </div>
-      <gf-form-switch class="gf-form" label-class="width-13" label="Render value as link" checked="style.link" change="editor.render()"></gf-form-switch>
     </div>
     </div>
+    <div class="gf-form" ng-if="style.type !== 'hidden'">
+      <label class="gf-form-label width-13">Column Header</label>
+      <input type="text" class="gf-form-input width-13" ng-model="style.alias" ng-change="editor.render()" ng-model-onblur placeholder="Override header label">
+    </div>
+    <gf-form-switch class="gf-form" label-class="width-13" label="Render value as link" checked="style.link" change="editor.render()"></gf-form-switch>
+  </div>
 
 
-    <div class="section gf-form-group">
-      <h5 class="section-heading">Type</h5>
+  <div class="section gf-form-group">
+    <h5 class="section-heading">Type</h5>
 
 
-      <div class="gf-form">
-        <label class="gf-form-label width-11">Type</label>
-        <div class="gf-form-select-wrapper width-16">
-          <select class="gf-form-input" ng-model="style.type" ng-options="c.value as c.text for c in editor.columnTypes" ng-change="editor.render()"></select>
-        </div>
+    <div class="gf-form">
+      <label class="gf-form-label width-11">Type</label>
+      <div class="gf-form-select-wrapper width-16">
+        <select class="gf-form-input" ng-model="style.type" ng-options="c.value as c.text for c in editor.columnTypes" ng-change="editor.render()"></select>
       </div>
       </div>
-      <div class="gf-form" ng-if="style.type === 'date'">
-        <label class="gf-form-label width-11">Date Format</label>
-        <gf-form-dropdown model="style.dateFormat" css-class="gf-form-input width-16" lookup-text="true"
+    </div>
+    <div class="gf-form" ng-if="style.type === 'date'">
+      <label class="gf-form-label width-11">Date Format</label>
+      <gf-form-dropdown model="style.dateFormat" css-class="gf-form-input width-16" lookup-text="true"
         	get-options="editor.dateFormats" on-change="editor.render()" allow-custom="true">
         	get-options="editor.dateFormats" on-change="editor.render()" allow-custom="true">
-        </gf-form-dropdown>
-      </div>
+      </gf-form-dropdown>
+    </div>
 
 
-      <div ng-if="style.type === 'string'">
-        <gf-form-switch class="gf-form" label-class="width-11" ng-if="style.type === 'string'" label="Sanitize HTML" checked="style.sanitize"
+    <div ng-if="style.type === 'string'">
+      <gf-form-switch class="gf-form" label-class="width-11" ng-if="style.type === 'string'" label="Sanitize HTML" checked="style.sanitize"
           change="editor.render()"></gf-form-switch>
           change="editor.render()"></gf-form-switch>
-      </div>
-      <div ng-if="style.type === 'string'">
-        <gf-form-switch class="gf-form" label-class="width-11" ng-if="style.type === 'string'" label="Preserve Formatting" checked="style.preserveFormat"
+    </div>
+    <div ng-if="style.type === 'string'">
+      <gf-form-switch class="gf-form" label-class="width-11" ng-if="style.type === 'string'" label="Preserve Formatting" checked="style.preserveFormat"
           change="editor.render()"></gf-form-switch>
           change="editor.render()"></gf-form-switch>
-      </div>
+    </div>
 
 
-      <div ng-if="style.type === 'number'">
-        <div class="gf-form">
-          <label class="gf-form-label width-11">Unit</label>
-          <div class="gf-form-dropdown-typeahead width-16" ng-model="style.unit" dropdown-typeahead2="editor.unitFormats" dropdown-typeahead-on-select="editor.setUnitFormat(style, $subItem)"></div>
-        </div>
-        <div class="gf-form">
-          <label class="gf-form-label width-11">Decimals</label>
-          <input type="number" class="gf-form-input width-4" data-placement="right" ng-model="style.decimals" ng-change="editor.render()"
+    <div ng-if="style.type === 'number'">
+      <div class="gf-form">
+        <label class="gf-form-label width-11">Unit</label>
+        <div class="gf-form-dropdown-typeahead width-16" ng-model="style.unit" dropdown-typeahead2="editor.unitFormats" dropdown-typeahead-on-select="editor.setUnitFormat(style, $subItem)"></div>
+      </div>
+      <div class="gf-form">
+        <label class="gf-form-label width-11">Decimals</label>
+        <input type="number" class="gf-form-input width-4" data-placement="right" ng-model="style.decimals" ng-change="editor.render()"
             ng-model-onblur>
             ng-model-onblur>
-        </div>
       </div>
       </div>
     </div>
     </div>
+  </div>
 
 
-    <div class="section gf-form-group" ng-if="style.type === 'string'">
-      <h5 class="section-heading">Value Mappings</h5>
-      <div class="editor-row">
-        <div class="gf-form-group">
-          <div class="gf-form">
+  <div class="section gf-form-group" ng-if="style.type === 'string'">
+    <h5 class="section-heading">Value Mappings</h5>
+    <div class="editor-row">
+      <div class="gf-form-group">
+        <div class="gf-form">
+          <span class="gf-form-label">
+            Type
+          </span>
+          <div class="gf-form-select-wrapper">
+            <select class="gf-form-input" ng-model="style.mappingType"
+                      ng-options="c.value as c.text for c in editor.mappingTypes" ng-change="editor.render()"></select>
+          </div>
+        </div>
+        <div class="gf-form-group" ng-if="style.mappingType==1">
+          <div class="gf-form" ng-repeat="map in style.valueMaps">
             <span class="gf-form-label">
             <span class="gf-form-label">
-              Type
+              <i class="fa fa-remove pointer" ng-click="editor.removeValueMap(style, $index)"></i>
             </span>
             </span>
-            <div class="gf-form-select-wrapper">
-              <select class="gf-form-input" ng-model="style.mappingType"
-                      ng-options="c.value as c.text for c in editor.mappingTypes" ng-change="editor.render()"></select>
-            </div>
+            <input type="text" class="gf-form-input max-width-6" ng-model="map.value" placeholder="Value" ng-blur="editor.render()">
+            <label class="gf-form-label">
+              <i class="fa fa-arrow-right"></i>
+            </label>
+            <input type="text" class="gf-form-input max-width-8" ng-model="map.text" placeholder="Text" ng-blur="editor.render()">
+          </div>
+          <div class="gf-form">
+            <label class="gf-form-label">
+              <a class="pointer" ng-click="editor.addValueMap(style)"><i class="fa fa-plus"></i></a>
+            </label>
           </div>
           </div>
-          <div class="gf-form-group" ng-if="style.mappingType==1">
-            <div class="gf-form" ng-repeat="map in style.valueMaps">
-              <span class="gf-form-label">
-                <i class="fa fa-remove pointer" ng-click="editor.removeValueMap(style, $index)"></i>
-              </span>
-              <input type="text" class="gf-form-input max-width-6" ng-model="map.value" placeholder="Value" ng-blur="editor.render()">
-              <label class="gf-form-label">
-                <i class="fa fa-arrow-right"></i>
-              </label>
-              <input type="text" class="gf-form-input max-width-8" ng-model="map.text" placeholder="Text" ng-blur="editor.render()">
-            </div>
-            <div class="gf-form">
-              <label class="gf-form-label">
-                <a class="pointer" ng-click="editor.addValueMap(style)"><i class="fa fa-plus"></i></a>
-              </label>
-            </div>
+        </div>
+        <div class="gf-form-group" ng-if="style.mappingType==2">
+          <div class="gf-form" ng-repeat="rangeMap in style.rangeMaps">
+            <span class="gf-form-label">
+              <i class="fa fa-remove pointer" ng-click="editor.removeRangeMap(style, $index)"></i>
+            </span>
+            <span class="gf-form-label">From</span>
+            <input type="text" ng-model="rangeMap.from" class="gf-form-input max-width-6" ng-blur="editor.render()">
+            <span class="gf-form-label">To</span>
+            <input type="text" ng-model="rangeMap.to" class="gf-form-input max-width-6" ng-blur="editor.render()">
+            <span class="gf-form-label">Text</span>
+            <input type="text" ng-model="rangeMap.text" class="gf-form-input max-width-8" ng-blur="editor.render()">
           </div>
           </div>
-          <div class="gf-form-group" ng-if="style.mappingType==2">
-            <div class="gf-form" ng-repeat="rangeMap in style.rangeMaps">
-              <span class="gf-form-label">
-                <i class="fa fa-remove pointer" ng-click="editor.removeRangeMap(style, $index)"></i>
-              </span>
-              <span class="gf-form-label">From</span>
-              <input type="text" ng-model="rangeMap.from" class="gf-form-input max-width-6" ng-blur="editor.render()">
-              <span class="gf-form-label">To</span>
-              <input type="text" ng-model="rangeMap.to" class="gf-form-input max-width-6" ng-blur="editor.render()">
-              <span class="gf-form-label">Text</span>
-              <input type="text" ng-model="rangeMap.text" class="gf-form-input max-width-8" ng-blur="editor.render()">
-            </div>
-            <div class="gf-form">
-              <label class="gf-form-label">
-                <a class="pointer" ng-click="editor.addRangeMap(style)"><i class="fa fa-plus"></i></a>
-              </label>
-            </div>
+          <div class="gf-form">
+            <label class="gf-form-label">
+              <a class="pointer" ng-click="editor.addRangeMap(style)"><i class="fa fa-plus"></i></a>
+            </label>
           </div>
           </div>
         </div>
         </div>
       </div>
       </div>
     </div>
     </div>
+  </div>
 
 
-    <div class="section gf-form-group" ng-if="['number', 'string'].indexOf(style.type) !== -1">
-      <h5 class="section-heading">Thresholds</h5>
-      <div class="gf-form">
-        <label class="gf-form-label width-8">Thresholds
-          <tip>Comma separated values</tip>
-        </label>
-        <input type="text" class="gf-form-input width-10" ng-model="style.thresholds" placeholder="50,80" ng-blur="editor.render()"
-          array-join>
-      </div>
-      <div class="gf-form">
-        <label class="gf-form-label width-8">Color Mode</label>
-        <div class="gf-form-select-wrapper width-10">
-          <select class="gf-form-input" ng-model="style.colorMode" ng-options="c.value as c.text for c in editor.colorModes" ng-change="editor.render()"></select>
-        </div>
+  <div class="section gf-form-group" ng-if="['number', 'string'].indexOf(style.type) !== -1">
+    <h5 class="section-heading">Thresholds</h5>
+    <div class="gf-form">
+      <label class="gf-form-label width-8">Thresholds
+        <tip>Comma separated values</tip>
+      </label>
+      <input type="text" class="gf-form-input width-10" ng-model="style.thresholds" placeholder="50,80" ng-blur="editor.render()"
+        array-join>
+    </div>
+    <div class="gf-form">
+      <label class="gf-form-label width-8">Color Mode</label>
+      <div class="gf-form-select-wrapper width-10">
+        <select class="gf-form-input" ng-model="style.colorMode" ng-options="c.value as c.text for c in editor.colorModes" ng-change="editor.render()"></select>
       </div>
       </div>
-      <div class="gf-form">
-        <label class="gf-form-label width-8">Colors</label>
-        <span class="gf-form-label">
-          <color-picker color="style.colors[0]" onChange="editor.onColorChange($index, 0)"></color-picker>
-        </span>
-        <span class="gf-form-label">
-          <color-picker color="style.colors[1]" onChange="editor.onColorChange($index, 1)"></color-picker>
-        </span>
-        <span class="gf-form-label">
-          <color-picker color="style.colors[2]" onChange="editor.onColorChange($index, 2)"></color-picker>
-        </span>
-        <div class="gf-form-label">
-          <a class="pointer" ng-click="editor.invertColorOrder($index)">Invert</a>
-        </div>
+    </div>
+    <div class="gf-form">
+      <label class="gf-form-label width-8">Colors</label>
+      <span class="gf-form-label">
+        <color-picker color="style.colors[0]" onChange="editor.onColorChange($index, 0)"></color-picker>
+      </span>
+      <span class="gf-form-label">
+        <color-picker color="style.colors[1]" onChange="editor.onColorChange($index, 1)"></color-picker>
+      </span>
+      <span class="gf-form-label">
+        <color-picker color="style.colors[2]" onChange="editor.onColorChange($index, 2)"></color-picker>
+      </span>
+      <div class="gf-form-label">
+        <a class="pointer" ng-click="editor.invertColorOrder($index)">Invert</a>
       </div>
       </div>
     </div>
     </div>
+  </div>
 
 
-    <div class="section gf-form-group" ng-if="style.link">
-      <h5 class="section-heading">Link</h5>
-      <div class="gf-form">
-        <label class="gf-form-label width-9">
-          Url
-          <info-popover mode="right-normal">
-            <p>Specify an URL (relative or absolute)</p>
-            <span>
-              Use special variables to specify cell values:
-              <br>
-              <em>${__cell}</em> refers to current cell value
-              <br>
-              <em>${__cell_n}</em> refers to Nth column value in current row. Column indexes are started from 0. For instance,
-              <em>${__cell_1}</em> refers to second column's value.
-              <br>
-              <em>${__cell:raw}</em> syntax. By default values are URI encoded. If the value is a complete URL you can disable all encoding using
-            </span>
-          </info-popover>
-        </label>
-        <input type="text" class="gf-form-input width-29" ng-model="style.linkUrl" ng-blur="editor.render()" ng-model-onblur data-placement="right">
-      </div>
-      <div class="gf-form">
-        <label class="gf-form-label width-9">
-          Tooltip
-          <info-popover mode="right-normal">
-            <p>Specify text for link tooltip.</p>
-            <span>
+  <div class="section gf-form-group" ng-if="style.link">
+    <h5 class="section-heading">Link</h5>
+    <div class="gf-form">
+      <label class="gf-form-label width-9">
+        Url
+        <info-popover mode="right-normal">
+          <p>Specify an URL (relative or absolute)</p>
+          <span>
+            Use special variables to specify cell values:
+            <br>
+            <em>${__cell}</em> refers to current cell value
+            <br>
+            <em>${__cell_n}</em> refers to Nth column value in current row. Column indexes are started from 0. For instance,
+            <em>${__cell_1}</em> refers to second column's value.
+            <br>
+            <em>${__cell:raw}</em> syntax. By default values are URI encoded. If the value is a complete URL you can disable all encoding using
+          </span>
+        </info-popover>
+      </label>
+      <input type="text" class="gf-form-input width-29" ng-model="style.linkUrl" ng-blur="editor.render()" ng-model-onblur data-placement="right">
+    </div>
+    <div class="gf-form">
+      <label class="gf-form-label width-9">
+        Tooltip
+        <info-popover mode="right-normal">
+          <p>Specify text for link tooltip.</p>
+          <span>
               This title appears when user hovers pointer over the cell with link. Use the same variables as for URL.
               This title appears when user hovers pointer over the cell with link. Use the same variables as for URL.
-            </span>
-          </info-popover></label>
-        <input type="text" class="gf-form-input width-29" ng-model="style.linkTooltip" ng-blur="editor.render()" ng-model-onblur
+          </span>
+        </info-popover>
+      </label>
+      <input type="text" class="gf-form-input width-29" ng-model="style.linkTooltip" ng-blur="editor.render()" ng-model-onblur
           data-placement="right">
           data-placement="right">
-      </div>
-      <gf-form-switch class="gf-form" label-class="width-9" label="Open in new tab" checked="style.linkTargetBlank"></gf-form-switch>
     </div>
     </div>
+    <gf-form-switch class="gf-form" label-class="width-9" label="Open in new tab" checked="style.linkTargetBlank"></gf-form-switch>
+  </div>
 
 
-    <div class="clearfix"></div>
-
+  <div class="clearfix"></div>
+  <div class="gf-form-group">
     <button class="btn btn-danger btn-small" ng-click="editor.removeColumnStyle(style)">
     <button class="btn btn-danger btn-small" ng-click="editor.removeColumnStyle(style)">
       <i class="fa fa-trash"></i> Remove Rule
       <i class="fa fa-trash"></i> Remove Rule
     </button>
     </button>
-    <br />
-    <br />
-
   </div>
   </div>
+</div>
+<div class="gf-form-button-row">
+  <button class="btn btn-inverse" ng-click="editor.addColumnStyle()">
+    <i class="fa fa-plus"></i>&nbsp;Add column style
+  </button>
+</div>

+ 5 - 1
public/app/types/index.ts

@@ -21,7 +21,7 @@ import {
   DataQueryOptions,
   DataQueryOptions,
   IntervalValues,
   IntervalValues,
 } from './series';
 } from './series';
-import { PanelProps, PanelOptionsProps, Threshold } from './panel';
+import { BasicGaugeColor, MappingType, PanelProps, PanelOptionsProps, RangeMap, Threshold, ValueMap } from './panel';
 import { PluginDashboard, PluginMeta, Plugin, PanelPlugin, PluginsState } from './plugins';
 import { PluginDashboard, PluginMeta, Plugin, PanelPlugin, PluginsState } from './plugins';
 import { Organization, OrganizationState } from './organization';
 import { Organization, OrganizationState } from './organization';
 import {
 import {
@@ -93,7 +93,11 @@ export {
   Threshold,
   Threshold,
   ValidationEvents,
   ValidationEvents,
   ValidationRule,
   ValidationRule,
+  ValueMap,
+  RangeMap,
   IntervalValues,
   IntervalValues,
+  MappingType,
+  BasicGaugeColor,
 };
 };
 
 
 export interface StoreState {
 export interface StoreState {

+ 27 - 0
public/app/types/panel.ts

@@ -36,3 +36,30 @@ export interface Threshold {
   color?: string;
   color?: string;
   canRemove: boolean;
   canRemove: boolean;
 }
 }
+
+export enum MappingType {
+  ValueToText = 1,
+  RangeToText = 2,
+}
+
+export enum BasicGaugeColor {
+  Green = 'rgba(50, 172, 45, 0.97)',
+  Orange = 'rgba(237, 129, 40, 0.89)',
+  Red = 'rgb(212, 74, 58)',
+}
+
+interface BaseMap {
+  id: number;
+  operator: string;
+  text: string;
+  type: MappingType;
+}
+
+export interface ValueMap extends BaseMap {
+  value: string;
+}
+
+export interface RangeMap extends BaseMap {
+  from: string;
+  to: string;
+}

+ 47 - 10
public/app/viz/Gauge.tsx

@@ -1,38 +1,42 @@
 import React, { PureComponent } from 'react';
 import React, { PureComponent } from 'react';
 import $ from 'jquery';
 import $ from 'jquery';
-import { Threshold, TimeSeriesVMs } from 'app/types';
+import { MappingType, RangeMap, Threshold, TimeSeriesVMs, ValueMap } from 'app/types';
 import config from '../core/config';
 import config from '../core/config';
 import kbn from '../core/utils/kbn';
 import kbn from '../core/utils/kbn';
 
 
 interface Props {
 interface Props {
   decimals: number;
   decimals: number;
+  height: number;
+  mappings: Array<RangeMap | ValueMap>;
+  maxValue: number;
+  minValue: number;
+  prefix: string;
   timeSeries: TimeSeriesVMs;
   timeSeries: TimeSeriesVMs;
-  showThresholdMarkers: boolean;
   thresholds: Threshold[];
   thresholds: Threshold[];
+  showThresholdMarkers: boolean;
   showThresholdLabels: boolean;
   showThresholdLabels: boolean;
-  unit: string;
-  width: number;
-  height: number;
   stat: string;
   stat: string;
-  prefix: string;
   suffix: string;
   suffix: string;
+  unit: string;
+  width: number;
 }
 }
 
 
 export class Gauge extends PureComponent<Props> {
 export class Gauge extends PureComponent<Props> {
   canvasElement: any;
   canvasElement: any;
 
 
   static defaultProps = {
   static defaultProps = {
-    minValue: 0,
     maxValue: 100,
     maxValue: 100,
+    mappings: [],
+    minValue: 0,
     prefix: '',
     prefix: '',
     showThresholdMarkers: true,
     showThresholdMarkers: true,
     showThresholdLabels: false,
     showThresholdLabels: false,
     suffix: '',
     suffix: '',
-    unit: 'none',
     thresholds: [
     thresholds: [
       { label: 'Min', value: 0, color: 'rgba(50, 172, 45, 0.97)' },
       { label: 'Min', value: 0, color: 'rgba(50, 172, 45, 0.97)' },
       { label: 'Max', value: 100, color: 'rgba(245, 54, 54, 0.9)' },
       { label: 'Max', value: 100, color: 'rgba(245, 54, 54, 0.9)' },
     ],
     ],
+    unit: 'none',
   };
   };
 
 
   componentDidMount() {
   componentDidMount() {
@@ -43,16 +47,49 @@ export class Gauge extends PureComponent<Props> {
     this.draw();
     this.draw();
   }
   }
 
 
+  formatWithMappings(mappings, value) {
+    const valueMaps = mappings.filter(m => m.type === MappingType.ValueToText);
+    const rangeMaps = mappings.filter(m => m.type === MappingType.RangeToText);
+
+    const valueMap = valueMaps.map(mapping => {
+      if (mapping.value && value === mapping.value) {
+        return mapping.text;
+      }
+    })[0];
+
+    const rangeMap = rangeMaps.map(mapping => {
+      if (mapping.from && mapping.to && value > mapping.from && value < mapping.to) {
+        return mapping.text;
+      }
+    })[0];
+
+    return {
+      rangeMap,
+      valueMap,
+    };
+  }
+
   formatValue(value) {
   formatValue(value) {
-    const { decimals, prefix, suffix, unit } = this.props;
+    const { decimals, mappings, prefix, suffix, unit } = this.props;
 
 
     const formatFunc = kbn.valueFormats[unit];
     const formatFunc = kbn.valueFormats[unit];
+    const formattedValue = formatFunc(value, decimals);
+
+    if (mappings.length > 0) {
+      const { rangeMap, valueMap } = this.formatWithMappings(mappings, formattedValue);
+
+      if (valueMap) {
+        return valueMap;
+      } else if (rangeMap) {
+        return rangeMap;
+      }
+    }
 
 
     if (isNaN(value)) {
     if (isNaN(value)) {
       return '-';
       return '-';
     }
     }
 
 
-    return `${prefix} ${formatFunc(value, decimals)} ${suffix}`;
+    return `${prefix} ${formattedValue} ${suffix}`;
   }
   }
 
 
   draw() {
   draw() {

+ 1 - 2
public/sass/_grafana.scss

@@ -96,8 +96,6 @@
 @import 'components/empty_list_cta';
 @import 'components/empty_list_cta';
 @import 'components/popper';
 @import 'components/popper';
 @import 'components/form_select_box';
 @import 'components/form_select_box';
-@import 'components/user-picker';
-@import 'components/description-picker';
 @import 'components/panel_editor';
 @import 'components/panel_editor';
 @import 'components/toolbar';
 @import 'components/toolbar';
 @import 'components/delete_button';
 @import 'components/delete_button';
@@ -106,6 +104,7 @@
 @import 'components/unit-picker';
 @import 'components/unit-picker';
 @import 'components/thresholds';
 @import 'components/thresholds';
 @import 'components/toggle_button_group';
 @import 'components/toggle_button_group';
+@import 'components/value-mappings';
 
 
 // PAGES
 // PAGES
 @import 'pages/login';
 @import 'pages/login';

+ 4 - 3
public/sass/_variables.dark.scss

@@ -271,7 +271,7 @@ $menu-dropdown-shadow: 5px 5px 20px -5px $black;
 $tab-border-color: $dark-4;
 $tab-border-color: $dark-4;
 
 
 // Toolbar
 // Toolbar
-$toolbar-bg: $black;
+$toolbar-bg: $input-black;
 
 
 // Pagination
 // Pagination
 // -------------------------
 // -------------------------
@@ -375,13 +375,14 @@ $checkbox-color: $dark-1;
 //Panel Edit
 //Panel Edit
 // -------------------------
 // -------------------------
 $panel-editor-shadow: 0 0 20px black;
 $panel-editor-shadow: 0 0 20px black;
+$panel-editor-border: 1px solid $dark-3;
 $panel-editor-side-menu-shadow: drop-shadow(0 0 10px $black);
 $panel-editor-side-menu-shadow: drop-shadow(0 0 10px $black);
-$panel-editor-toolbar-view-bg: $black;
+$panel-editor-toolbar-view-bg: $input-black;
 $panel-editor-viz-item-shadow: 0 0 8px $dark-5;
 $panel-editor-viz-item-shadow: 0 0 8px $dark-5;
 $panel-editor-viz-item-border: 1px solid $dark-5;
 $panel-editor-viz-item-border: 1px solid $dark-5;
 $panel-editor-viz-item-shadow-hover: 0 0 4px $blue;
 $panel-editor-viz-item-shadow-hover: 0 0 4px $blue;
 $panel-editor-viz-item-border-hover: 1px solid $blue;
 $panel-editor-viz-item-border-hover: 1px solid $blue;
-$panel-editor-viz-item-bg: $black;
+$panel-editor-viz-item-bg: $input-black;
 $panel-editor-tabs-line-color: #e3e3e3;
 $panel-editor-tabs-line-color: #e3e3e3;
 $panel-editor-viz-item-bg-hover: darken($blue, 47%);
 $panel-editor-viz-item-bg-hover: darken($blue, 47%);
 $panel-editor-viz-item-bg-hover-active: darken($orange, 45%);
 $panel-editor-viz-item-bg-hover-active: darken($orange, 45%);

+ 1 - 0
public/sass/_variables.light.scss

@@ -384,6 +384,7 @@ $checkbox-color: $gray-7;
 //Panel Edit
 //Panel Edit
 // -------------------------
 // -------------------------
 $panel-editor-shadow: 2px 2px 8px $gray-3;
 $panel-editor-shadow: 2px 2px 8px $gray-3;
+$panel-editor-border: 1px solid $dark-4;
 $panel-editor-side-menu-shadow: drop-shadow(0 0 2px $gray-3);
 $panel-editor-side-menu-shadow: drop-shadow(0 0 2px $gray-3);
 $panel-editor-toolbar-view-bg: $white;
 $panel-editor-toolbar-view-bg: $white;
 $panel-editor-viz-item-shadow: 0 0 4px $gray-3;
 $panel-editor-viz-item-shadow: 0 0 4px $gray-3;

+ 1 - 0
public/sass/components/_buttons.scss

@@ -78,6 +78,7 @@
 
 
 .btn-link {
 .btn-link {
   color: $btn-link-color;
   color: $btn-link-color;
+  background: transparent;
 }
 }
 
 
 // Set the backgrounds
 // Set the backgrounds

+ 0 - 11
public/sass/components/_description-picker.scss

@@ -1,11 +0,0 @@
-.description-picker-option__button {
-  position: relative;
-  text-align: left;
-  width: 100%;
-  display: block;
-  border-radius: 0;
-  white-space: normal;
-  i.fa-check {
-    padding-left: 2px;
-  }
-}

+ 43 - 3
public/sass/components/_form_select_box.scss

@@ -47,12 +47,17 @@ $select-input-bg-disabled: $input-bg-disabled;
 
 
 .gf-form-select-box__input {
 .gf-form-select-box__input {
   padding-left: 5px;
   padding-left: 5px;
+  input {
+    line-height: inherit;
+  }
 }
 }
 
 
 .gf-form-select-box__menu {
 .gf-form-select-box__menu {
-  background: $dropdownBackground;
+  background: $input-bg;
+  box-shadow: $menu-dropdown-shadow;
   position: absolute;
   position: absolute;
   z-index: 2;
   z-index: 2;
+  min-width: 100%;
 }
 }
 
 
 .gf-form-select-box__menu-list {
 .gf-form-select-box__menu-list {
@@ -64,16 +69,20 @@ $select-input-bg-disabled: $input-bg-disabled;
   width: 100%;
   width: 100%;
 }
 }
 
 
+/* .gf-form-select-box__single-value { */
+/* } */
+
 .gf-form-select-box__multi-value {
 .gf-form-select-box__multi-value {
   display: inline;
   display: inline;
 }
 }
 
 
 .gf-form-select-box__option {
 .gf-form-select-box__option {
   border-left: 2px solid transparent;
   border-left: 2px solid transparent;
+  white-space: nowrap;
 
 
   &.gf-form-select-box__option--is-focused {
   &.gf-form-select-box__option--is-focused {
     color: $dropdownLinkColorHover;
     color: $dropdownLinkColorHover;
-    background-color: $dropdownLinkBackgroundHover;
+    background: $menu-dropdown-hover-bg;
     @include left-brand-border-gradient();
     @include left-brand-border-gradient();
   }
   }
 
 
@@ -90,7 +99,7 @@ $select-input-bg-disabled: $input-bg-disabled;
 
 
 .gf-form-select-box__value-container {
 .gf-form-select-box__value-container {
   display: table-cell;
   display: table-cell;
-  padding: 8px 10px;
+  padding: 6px 10px;
   > div {
   > div {
     display: inline-block;
     display: inline-block;
   }
   }
@@ -119,10 +128,12 @@ $select-input-bg-disabled: $input-bg-disabled;
     border-width: 0 5px 5px;
     border-width: 0 5px 5px;
   }
   }
 }
 }
+
 .gf-form-input--form-dropdown {
 .gf-form-input--form-dropdown {
   padding: 0;
   padding: 0;
   border: 0;
   border: 0;
   overflow: visible;
   overflow: visible;
+  position: relative;
 }
 }
 
 
 .gf-form--has-input-icon {
 .gf-form--has-input-icon {
@@ -130,3 +141,32 @@ $select-input-bg-disabled: $input-bg-disabled;
     padding-left: 30px;
     padding-left: 30px;
   }
   }
 }
 }
+
+.gf-form-select-box__desc-option {
+  display: flex;
+  align-items: center;
+  justify-content: flex-start;
+  justify-items: center;
+  cursor: pointer;
+  padding: 7px 10px;
+  width: 100%;
+}
+
+.gf-form-select-box__desc-option__body {
+  display: flex;
+  flex-direction: column;
+  flex-grow: 1;
+  padding-right: 10px;
+  font-weight: 500;
+}
+
+.gf-form-select-box__desc-option__desc {
+  font-weight: normal;
+  font-size: $font-size-sm;
+  color: $text-muted;
+}
+
+.gf-form-select-box__desc-option__img {
+  width: 16px;
+  margin-right: 10px;
+}

+ 0 - 1
public/sass/components/_gf-form.scss

@@ -110,7 +110,6 @@ $input-border: 1px solid $input-border-color;
 
 
   &--grow {
   &--grow {
     flex-grow: 1;
     flex-grow: 1;
-    min-height: 2.6rem;
   }
   }
 
 
   &--error {
   &--error {

+ 5 - 3
public/sass/components/_json_explorer.scss

@@ -21,10 +21,10 @@
         display: none;
         display: none;
       }
       }
       &.json-formatter-object::after {
       &.json-formatter-object::after {
-        content: "No properties";
+        content: 'No properties';
       }
       }
       &.json-formatter-array::after {
       &.json-formatter-array::after {
-        content: "[]";
+        content: '[]';
       }
       }
     }
     }
   }
   }
@@ -33,7 +33,9 @@
     color: $json-explorer-string-color;
     color: $json-explorer-string-color;
     white-space: normal;
     white-space: normal;
     word-wrap: break-word;
     word-wrap: break-word;
+    word-break: break-all;
   }
   }
+
   .json-formatter-number {
   .json-formatter-number {
     color: $json-explorer-number-color;
     color: $json-explorer-number-color;
   }
   }
@@ -87,7 +89,7 @@
     &::after {
     &::after {
       display: inline-block;
       display: inline-block;
       transition: transform $json-explorer-rotate-time ease-in;
       transition: transform $json-explorer-rotate-time ease-in;
-      content: "►";
+      content: '►';
     }
     }
   }
   }
 
 

+ 30 - 5
public/sass/components/_panel_editor.scss

@@ -34,7 +34,6 @@
   flex-grow: 1;
   flex-grow: 1;
   background: $page-bg;
   background: $page-bg;
   margin: 0 20px 0 84px;
   margin: 0 20px 0 84px;
-  border-left: 2px solid $orange;
   border-radius: 3px;
   border-radius: 3px;
   box-shadow: $panel-editor-shadow;
   box-shadow: $panel-editor-shadow;
 }
 }
@@ -133,14 +132,19 @@
 }
 }
 
 
 .viz-picker {
 .viz-picker {
+  margin-top: -40px;
+  padding: 20px;
+  position: relative;
+}
+
+.viz-picker-list {
   display: flex;
   display: flex;
   flex-wrap: wrap;
   flex-wrap: wrap;
-  margin-bottom: 13px;
 }
 }
 
 
 .viz-picker__item {
 .viz-picker__item {
-  background: $panel-editor-viz-item-bg;
-  border: $panel-editor-viz-item-border;
+  background: $panel-bg;
+  border: $panel-border;
   border-radius: 3px;
   border-radius: 3px;
   height: 100px;
   height: 100px;
   width: 150px;
   width: 150px;
@@ -162,7 +166,7 @@
     border: 1px solid $orange;
     border: 1px solid $orange;
   }
   }
 
 
-  &--selected {
+  &:hover {
     box-shadow: $panel-editor-viz-item-shadow-hover;
     box-shadow: $panel-editor-viz-item-shadow-hover;
     background: $panel-editor-viz-item-bg-hover;
     background: $panel-editor-viz-item-bg-hover;
     border: $panel-editor-viz-item-border-hover;
     border: $panel-editor-viz-item-border-hover;
@@ -273,6 +277,20 @@
   }
   }
 }
 }
 
 
+.ds-picker {
+  position: relative;
+  min-width: 200px;
+}
+
+.ds-picker-menu {
+  min-width: 400px;
+  max-width: 500px;
+  position: absolute;
+  background: $panel-editor-toolbar-view-bg;
+  padding: 5px;
+  overflow: auto;
+}
+
 .ds-picker-list__name {
 .ds-picker-list__name {
   text-overflow: ellipsis;
   text-overflow: ellipsis;
   overflow: hidden;
   overflow: hidden;
@@ -306,6 +324,13 @@
   margin-bottom: 20px;
   margin-bottom: 20px;
   background: $input-label-bg;
   background: $input-label-bg;
   border-radius: 3px;
   border-radius: 3px;
+  position: relative;
+
+  .btn {
+    position: absolute;
+    right: 0;
+    top: 2px;
+  }
 }
 }
 
 
 .form-section__body {
 .form-section__body {

+ 2 - 1
public/sass/components/_thresholds.scss

@@ -77,7 +77,8 @@
   display: flex;
   display: flex;
   align-items: center;
   align-items: center;
   justify-content: center;
   justify-content: center;
-  width: 36px;
+  height: 37px;
+  width: 37px;
   cursor: pointer;
   cursor: pointer;
 }
 }
 
 

+ 0 - 12
public/sass/components/_user-picker.scss

@@ -1,12 +0,0 @@
-.user-picker-option__button {
-  position: relative;
-  text-align: left;
-  width: 100%;
-  display: block;
-  border-radius: 0;
-}
-.user-picker-option__avatar {
-  width: 20px;
-  display: inline-block;
-  margin-right: 10px;
-}

+ 37 - 0
public/sass/components/_value-mappings.scss

@@ -0,0 +1,37 @@
+.mapping-row {
+  display: flex;
+  margin-bottom: 10px;
+}
+
+.mapping-row-type {
+  margin-right: 5px;
+}
+
+.mapping-row-input {
+  margin-right: 5px;
+}
+
+.add-mapping-row {
+  display: flex;
+  overflow: hidden;
+  height: 37px;
+  cursor: pointer;
+  border-radius: $border-radius;
+  width: 200px;
+}
+
+.add-mapping-row-icon {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 36px;
+  background-color: $green;
+}
+
+.add-mapping-row-label {
+  align-items: center;
+  display: flex;
+  padding: 5px 8px;
+  background-color: $input-label-bg;
+  width: calc(100% - 36px);
+}

+ 4 - 0
public/sass/components/edit_sidemenu.scss

@@ -28,6 +28,10 @@
   }
   }
 }
 }
 
 
+.column-styles-heading {
+  border-bottom: 1px solid $gray-1;
+}
+
 @include media-breakpoint-down(sm) {
 @include media-breakpoint-down(sm) {
   .edit-tab-with-sidemenu {
   .edit-tab-with-sidemenu {
     flex-direction: column;
     flex-direction: column;

+ 4 - 0
public/sass/utils/_utils.scss

@@ -83,6 +83,10 @@ button.close {
   position: absolute;
   position: absolute;
 }
 }
 
 
+.flex-grow {
+  flex-grow: 1;
+}
+
 .center-vh {
 .center-vh {
   display: flex;
   display: flex;
   align-items: center;
   align-items: center;