浏览代码

Merge pull request #14274 from grafana/develop

Develop (New Panel Edit UX & Explore All Datasources suppport) -> Master
Torkel Ödegaard 7 年之前
父节点
当前提交
3efaf52049
共有 100 个文件被更改,包括 3438 次插入1221 次删除
  1. 1 0
      .gitignore
  2. 2 2
      README.md
  3. 9 7
      package.json
  4. 11 11
      packaging/publish/publish_both.sh
  5. 1 0
      pkg/plugins/datasource_plugin.go
  6. 38 0
      public/app/core/components/Animations/FadeIn.tsx
  7. 1 1
      public/app/core/components/Animations/SlideDown.tsx
  8. 1 0
      public/app/core/components/AppNotifications/AppNotificationItem.tsx
  9. 36 0
      public/app/core/components/ClickOutsideWrapper/ClickOutsideWrapper.tsx
  10. 67 0
      public/app/core/components/CopyToClipboard/CopyToClipboard.tsx
  11. 2 2
      public/app/core/components/CustomScrollbar/CustomScrollbar.tsx
  12. 4 4
      public/app/core/components/CustomScrollbar/__snapshots__/CustomScrollbar.test.tsx.snap
  13. 43 0
      public/app/core/components/Form/Element.tsx
  14. 53 0
      public/app/core/components/Form/Input.test.tsx
  15. 94 0
      public/app/core/components/Form/Input.tsx
  16. 19 0
      public/app/core/components/Form/Label.tsx
  17. 11 0
      public/app/core/components/Form/__snapshots__/Input.test.tsx.snap
  18. 3 0
      public/app/core/components/Form/index.ts
  19. 51 0
      public/app/core/components/JSONFormatter/JSONFormatter.tsx
  20. 1 0
      public/app/core/components/Label/Label.tsx
  21. 9 9
      public/app/core/components/PermissionList/AddPermission.tsx
  22. 8 7
      public/app/core/components/PermissionList/DisabledPermissionListItem.tsx
  23. 9 7
      public/app/core/components/PermissionList/PermissionListItem.tsx
  24. 0 25
      public/app/core/components/Picker/DescriptionOption.tsx
  25. 0 52
      public/app/core/components/Picker/DescriptionPicker.tsx
  26. 0 18
      public/app/core/components/Picker/NoOptionsMessage.tsx
  27. 0 22
      public/app/core/components/Picker/PickerOption.tsx
  28. 0 49
      public/app/core/components/Picker/SimplePicker.tsx
  29. 0 17
      public/app/core/components/Picker/__snapshots__/PickerOption.test.tsx.snap
  30. 35 0
      public/app/core/components/Portal/Portal.tsx
  31. 72 0
      public/app/core/components/Select/DataSourcePicker.tsx
  32. 2 2
      public/app/core/components/Select/IndicatorsContainer.tsx
  33. 20 0
      public/app/core/components/Select/NoOptionsMessage.tsx
  34. 53 0
      public/app/core/components/Select/OptionGroup.tsx
  35. 2 2
      public/app/core/components/Select/PickerOption.test.tsx
  36. 44 0
      public/app/core/components/Select/PickerOption.tsx
  37. 4 2
      public/app/core/components/Select/ResetStyles.tsx
  38. 232 0
      public/app/core/components/Select/Select.tsx
  39. 0 0
      public/app/core/components/Select/TeamPicker.test.tsx
  40. 4 18
      public/app/core/components/Select/TeamPicker.tsx
  41. 51 0
      public/app/core/components/Select/UnitPicker.tsx
  42. 0 0
      public/app/core/components/Select/UserPicker.test.tsx
  43. 11 18
      public/app/core/components/Select/UserPicker.tsx
  44. 21 0
      public/app/core/components/Select/__snapshots__/PickerOption.test.tsx.snap
  45. 0 29
      public/app/core/components/Select/__snapshots__/TeamPicker.test.tsx.snap
  46. 0 29
      public/app/core/components/Select/__snapshots__/UserPicker.test.tsx.snap
  47. 13 15
      public/app/core/components/SharedPreferences/SharedPreferences.tsx
  48. 9 16
      public/app/core/components/Switch/Switch.tsx
  49. 6 5
      public/app/core/components/TagFilter/TagFilter.tsx
  50. 1 1
      public/app/core/components/TagFilter/TagOption.tsx
  51. 18 35
      public/app/core/components/ToggleButtonGroup/ToggleButtonGroup.tsx
  52. 11 26
      public/app/core/components/Tooltip/Popover.tsx
  53. 72 0
      public/app/core/components/Tooltip/Popper.tsx
  54. 9 28
      public/app/core/components/Tooltip/Tooltip.tsx
  55. 2 2
      public/app/core/components/Tooltip/__snapshots__/Popover.test.tsx.snap
  56. 3 3
      public/app/core/components/Tooltip/__snapshots__/Tooltip.test.tsx.snap
  57. 88 0
      public/app/core/components/Tooltip/withPopper.tsx
  58. 0 58
      public/app/core/components/Tooltip/withTooltip.tsx
  59. 1 1
      public/app/core/components/code_editor/code_editor.ts
  60. 9 22
      public/app/core/components/colorpicker/ColorPicker.tsx
  61. 2 2
      public/app/core/components/manage_dashboards/manage_dashboards.html
  62. 8 8
      public/app/core/components/search/search_results.html
  63. 45 9
      public/app/core/components/switch.ts
  64. 4 0
      public/app/core/constants.ts
  65. 6 3
      public/app/core/directives/dash_class.ts
  66. 2 2
      public/app/core/directives/dropdown_typeahead.ts
  67. 1 0
      public/app/core/reducers/location.ts
  68. 8 0
      public/app/core/services/AngularLoader.ts
  69. 1 1
      public/app/core/services/bridge_srv.ts
  70. 2 3
      public/app/core/table_model.ts
  71. 1 1
      public/app/core/utils/connectWithReduxStore.tsx
  72. 2 1
      public/app/core/utils/kbn.ts
  73. 9 0
      public/app/core/utils/rangeutil.ts
  74. 16 0
      public/app/core/utils/validate.ts
  75. 4 1
      public/app/features/alerting/AlertTabCtrl.ts
  76. 175 171
      public/app/features/alerting/partials/alert_tab.html
  77. 1 1
      public/app/features/dashboard/dashboard_migration.ts
  78. 0 10
      public/app/features/dashboard/dashboard_model.ts
  79. 71 168
      public/app/features/dashboard/dashgrid/AddPanelPanel.tsx
  80. 72 0
      public/app/features/dashboard/dashgrid/AlertTab.tsx
  81. 15 14
      public/app/features/dashboard/dashgrid/DashboardGrid.tsx
  82. 1 1
      public/app/features/dashboard/dashgrid/DashboardGridDirective.ts
  83. 92 76
      public/app/features/dashboard/dashgrid/DashboardPanel.tsx
  84. 42 17
      public/app/features/dashboard/dashgrid/DataPanel.tsx
  85. 31 0
      public/app/features/dashboard/dashgrid/DataSourceOption.tsx
  86. 133 0
      public/app/features/dashboard/dashgrid/EditorTabBody.tsx
  87. 52 0
      public/app/features/dashboard/dashgrid/GeneralTab.tsx
  88. 71 0
      public/app/features/dashboard/dashgrid/KeyboardNavigation.tsx
  89. 76 44
      public/app/features/dashboard/dashgrid/PanelChrome.tsx
  90. 87 70
      public/app/features/dashboard/dashgrid/PanelEditor.tsx
  91. 63 26
      public/app/features/dashboard/dashgrid/PanelHeader/PanelHeader.tsx
  92. 94 0
      public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderCorner.tsx
  93. 2 1
      public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenu.tsx
  94. 26 0
      public/app/features/dashboard/dashgrid/PanelOptionSection.tsx
  95. 64 0
      public/app/features/dashboard/dashgrid/PanelPluginNotFound.tsx
  96. 244 12
      public/app/features/dashboard/dashgrid/QueriesTab.tsx
  97. 220 0
      public/app/features/dashboard/dashgrid/QueryInspector.tsx
  98. 167 0
      public/app/features/dashboard/dashgrid/QueryOptions.tsx
  99. 221 0
      public/app/features/dashboard/dashgrid/VisualizationTab.tsx
  100. 40 34
      public/app/features/dashboard/dashgrid/VizTypePicker.tsx

+ 1 - 0
.gitignore

@@ -76,3 +76,4 @@ debug.test
 /devenv/bulk_alerting_dashboards/*.json
 /devenv/bulk_alerting_dashboards/*.json
 
 
 /scripts/build/release_publisher/release_publisher
 /scripts/build/release_publisher/release_publisher
+*.patch

+ 2 - 2
README.md

@@ -25,7 +25,7 @@ the latest master builds [here](https://grafana.com/grafana/download)
 ### Dependencies
 ### Dependencies
 
 
 - Go (Latest Stable)
 - Go (Latest Stable)
-- NodeJS LTS
+- Node.js LTS
 
 
 ### Building the backend
 ### Building the backend
 ```bash
 ```bash
@@ -37,7 +37,7 @@ go run build.go build
 
 
 ### Building frontend assets
 ### Building frontend assets
 
 
-For this you need nodejs (v.6+).
+For this you need Node.js (LTS version).
 
 
 To build the assets, rebuild on file change, and serve them by Grafana's webserver (http://localhost:3000):
 To build the assets, rebuild on file change, and serve them by Grafana's webserver (http://localhost:3000):
 ```bash
 ```bash

+ 9 - 7
package.json

@@ -20,9 +20,9 @@
     "@types/enzyme": "^3.1.13",
     "@types/enzyme": "^3.1.13",
     "@types/jest": "^23.3.2",
     "@types/jest": "^23.3.2",
     "@types/node": "^8.0.31",
     "@types/node": "^8.0.31",
-    "@types/react": "^16.4.14",
+    "@types/react": "^16.7.6",
     "@types/react-custom-scrollbars": "^4.0.5",
     "@types/react-custom-scrollbars": "^4.0.5",
-    "@types/react-dom": "^16.0.7",
+    "@types/react-dom": "^16.0.9",
     "@types/react-select": "^2.0.4",
     "@types/react-select": "^2.0.4",
     "angular-mocks": "1.6.6",
     "angular-mocks": "1.6.6",
     "autoprefixer": "^6.4.0",
     "autoprefixer": "^6.4.0",
@@ -148,17 +148,18 @@
     "prismjs": "^1.6.0",
     "prismjs": "^1.6.0",
     "prop-types": "^15.6.2",
     "prop-types": "^15.6.2",
     "rc-cascader": "^0.14.0",
     "rc-cascader": "^0.14.0",
-    "react": "^16.5.0",
+    "react": "^16.6.3",
     "react-custom-scrollbars": "^4.2.1",
     "react-custom-scrollbars": "^4.2.1",
-    "react-dom": "^16.5.0",
+    "react-dom": "^16.6.3",
     "react-grid-layout": "0.16.6",
     "react-grid-layout": "0.16.6",
+    "react-popper": "^1.3.0",
     "react-highlight-words": "0.11.0",
     "react-highlight-words": "0.11.0",
-    "react-popper": "^0.7.5",
     "react-redux": "^5.0.7",
     "react-redux": "^5.0.7",
-    "react-select": "2.1.0",
+    "@torkelo/react-select": "2.1.1",
     "react-sizeme": "^2.3.6",
     "react-sizeme": "^2.3.6",
     "react-table": "^6.8.6",
     "react-table": "^6.8.6",
     "react-transition-group": "^2.2.1",
     "react-transition-group": "^2.2.1",
+    "react-virtualized": "^9.21.0",
     "redux": "^4.0.0",
     "redux": "^4.0.0",
     "redux-logger": "^3.0.6",
     "redux-logger": "^3.0.6",
     "redux-thunk": "^2.3.0",
     "redux-thunk": "^2.3.0",
@@ -175,6 +176,7 @@
     "tslint-react": "^3.6.0"
     "tslint-react": "^3.6.0"
   },
   },
   "resolutions": {
   "resolutions": {
-    "caniuse-db": "1.0.30000772"
+    "caniuse-db": "1.0.30000772",
+    "**/@types/react": "16.7.6"
   }
   }
 }
 }

+ 11 - 11
packaging/publish/publish_both.sh

@@ -1,17 +1,17 @@
 #! /usr/bin/env bash
 #! /usr/bin/env bash
-version=5.4.1
+version=5.4.2
 
 
-wget https://dl.grafana.com/oss/release/grafana_${version}_amd64.deb
+# wget https://dl.grafana.com/oss/release/grafana_${version}_amd64.deb
+#
+# package_cloud push grafana/stable/debian/jessie grafana_${version}_amd64.deb
+# package_cloud push grafana/stable/debian/wheezy grafana_${version}_amd64.deb
+# package_cloud push grafana/stable/debian/stretch grafana_${version}_amd64.deb
+#
+# package_cloud push grafana/testing/debian/jessie grafana_${version}_amd64.deb
+# package_cloud push grafana/testing/debian/wheezy grafana_${version}_amd64.deb --verbose
+# package_cloud push grafana/testing/debian/stretch grafana_${version}_amd64.deb --verbose
 
 
-package_cloud push grafana/stable/debian/jessie grafana_${version}_amd64.deb
-package_cloud push grafana/stable/debian/wheezy grafana_${version}_amd64.deb
-package_cloud push grafana/stable/debian/stretch grafana_${version}_amd64.deb
-
-package_cloud push grafana/testing/debian/jessie grafana_${version}_amd64.deb
-package_cloud push grafana/testing/debian/wheezy grafana_${version}_amd64.deb --verbose
-package_cloud push grafana/testing/debian/stretch grafana_${version}_amd64.deb --verbose
-
-wget https://dl.grafana.com/release/grafana-${version}-1.x86_64.rpm
+wget https://dl.grafana.com/oss/release/grafana-${version}-1.x86_64.rpm
 
 
 package_cloud push grafana/testing/el/6 grafana-${version}-1.x86_64.rpm --verbose
 package_cloud push grafana/testing/el/6 grafana-${version}-1.x86_64.rpm --verbose
 package_cloud push grafana/testing/el/7 grafana-${version}-1.x86_64.rpm --verbose
 package_cloud push grafana/testing/el/7 grafana-${version}-1.x86_64.rpm --verbose

+ 1 - 0
pkg/plugins/datasource_plugin.go

@@ -24,6 +24,7 @@ type DataSourcePlugin struct {
 	Metrics      bool              `json:"metrics"`
 	Metrics      bool              `json:"metrics"`
 	Alerting     bool              `json:"alerting"`
 	Alerting     bool              `json:"alerting"`
 	Explore      bool              `json:"explore"`
 	Explore      bool              `json:"explore"`
+	Table        bool              `json:"tables"`
 	Logs         bool              `json:"logs"`
 	Logs         bool              `json:"logs"`
 	QueryOptions map[string]bool   `json:"queryOptions,omitempty"`
 	QueryOptions map[string]bool   `json:"queryOptions,omitempty"`
 	BuiltIn      bool              `json:"builtIn,omitempty"`
 	BuiltIn      bool              `json:"builtIn,omitempty"`

+ 38 - 0
public/app/core/components/Animations/FadeIn.tsx

@@ -0,0 +1,38 @@
+import React, { SFC } from 'react';
+import Transition from 'react-transition-group/Transition';
+
+interface Props {
+  duration: number;
+  children: JSX.Element;
+  in: boolean;
+  unmountOnExit?: boolean;
+}
+
+export const FadeIn: SFC<Props> = props => {
+  const defaultStyle = {
+    transition: `opacity ${props.duration}ms linear`,
+    opacity: 0,
+  };
+
+  const transitionStyles = {
+    exited: { opacity: 0, display: 'none' },
+    entering: { opacity: 0 },
+    entered: { opacity: 1 },
+    exiting: { opacity: 0 },
+  };
+
+  return (
+    <Transition in={props.in} timeout={props.duration} unmountOnExit={props.unmountOnExit || false}>
+      {state => (
+        <div
+          style={{
+            ...defaultStyle,
+            ...transitionStyles[state],
+          }}
+        >
+          {props.children}
+        </div>
+      )}
+    </Transition>
+  );
+};

+ 1 - 1
public/app/core/components/Animations/SlideDown.tsx

@@ -23,7 +23,7 @@ export default ({ children, in: inProp, maxHeight = defaultMaxHeight, style = de
   const transitionStyles = {
   const transitionStyles = {
     exited: { maxHeight: 0 },
     exited: { maxHeight: 0 },
     entering: { maxHeight: maxHeight },
     entering: { maxHeight: maxHeight },
-    entered: { maxHeight: maxHeight, overflow: 'visible' },
+    entered: { maxHeight: 'unset', overflow: 'visible' },
     exiting: { maxHeight: 0 },
     exiting: { maxHeight: 0 },
   };
   };
 
 

+ 1 - 0
public/app/core/components/AppNotifications/AppNotificationItem.tsx

@@ -20,6 +20,7 @@ export default class AppNotificationItem extends Component<Props> {
 
 
   render() {
   render() {
     const { appNotification, onClearNotification } = this.props;
     const { appNotification, onClearNotification } = this.props;
+
     return (
     return (
       <div className={`alert-${appNotification.severity} alert`}>
       <div className={`alert-${appNotification.severity} alert`}>
         <div className="alert-icon">
         <div className="alert-icon">

+ 36 - 0
public/app/core/components/ClickOutsideWrapper/ClickOutsideWrapper.tsx

@@ -0,0 +1,36 @@
+import { PureComponent } from 'react';
+import ReactDOM from 'react-dom';
+
+export interface Props {
+  onClick: () => void;
+}
+
+interface State {
+  hasEventListener: boolean;
+}
+
+export class ClickOutsideWrapper extends PureComponent<Props, State> {
+  state = {
+    hasEventListener: false,
+  };
+
+  componentDidMount() {
+    window.addEventListener('click', this.onOutsideClick, false);
+  }
+
+  componentWillUnmount() {
+    window.removeEventListener('click', this.onOutsideClick, false);
+  }
+
+  onOutsideClick = event => {
+    const domNode = ReactDOM.findDOMNode(this) as Element;
+
+    if (!domNode || !domNode.contains(event.target)) {
+      this.props.onClick();
+    }
+  };
+
+  render() {
+    return this.props.children;
+  }
+}

+ 67 - 0
public/app/core/components/CopyToClipboard/CopyToClipboard.tsx

@@ -0,0 +1,67 @@
+import React, { PureComponent, ReactNode } from 'react';
+import ClipboardJS from 'clipboard';
+
+interface Props {
+  text: () => string;
+  elType?: string;
+  onSuccess?: (evt: any) => void;
+  onError?: (evt: any) => void;
+  className?: string;
+  children?: ReactNode;
+}
+
+export class CopyToClipboard extends PureComponent<Props> {
+  clipboardjs: any;
+  myRef: any;
+
+  constructor(props) {
+    super(props);
+    this.myRef = React.createRef();
+  }
+
+  componentDidMount() {
+    const { text, onSuccess, onError } = this.props;
+
+    this.clipboardjs = new ClipboardJS(this.myRef.current, {
+      text: text,
+    });
+
+    if (onSuccess) {
+      this.clipboardjs.on('success', evt => {
+        evt.clearSelection();
+        onSuccess(evt);
+      });
+    }
+
+    if (onError) {
+      this.clipboardjs.on('error', evt => {
+        console.error('Action:', evt.action);
+        console.error('Trigger:', evt.trigger);
+        onError(evt);
+      });
+    }
+  }
+
+  componentWillUnmount() {
+    if (this.clipboardjs) {
+      this.clipboardjs.destroy();
+    }
+  }
+
+  getElementType = () => {
+    return this.props.elType || 'button';
+  };
+
+  render() {
+    const { elType, text, children, onError, onSuccess, ...restProps } = this.props;
+
+    return React.createElement(
+      this.getElementType(),
+      {
+        ref: this.myRef,
+        ...restProps,
+      },
+      this.props.children
+    );
+  }
+}

+ 2 - 2
public/app/core/components/CustomScrollbar/CustomScrollbar.tsx

@@ -28,8 +28,8 @@ class CustomScrollbar extends PureComponent<Props> {
       <Scrollbars
       <Scrollbars
         className={customClassName}
         className={customClassName}
         autoHeight={true}
         autoHeight={true}
-        autoHeightMin={'100%'}
-        autoHeightMax={'100%'}
+        autoHeightMin={'inherit'}
+        autoHeightMax={'inherit'}
         renderTrackHorizontal={props => <div {...props} className="track-horizontal" />}
         renderTrackHorizontal={props => <div {...props} className="track-horizontal" />}
         renderTrackVertical={props => <div {...props} className="track-vertical" />}
         renderTrackVertical={props => <div {...props} className="track-vertical" />}
         renderThumbHorizontal={props => <div {...props} className="thumb-horizontal" />}
         renderThumbHorizontal={props => <div {...props} className="thumb-horizontal" />}

+ 4 - 4
public/app/core/components/CustomScrollbar/__snapshots__/CustomScrollbar.test.tsx.snap

@@ -6,8 +6,8 @@ exports[`CustomScrollbar renders correctly 1`] = `
   style={
   style={
     Object {
     Object {
       "height": "auto",
       "height": "auto",
-      "maxHeight": "100%",
-      "minHeight": "100%",
+      "maxHeight": "inherit",
+      "minHeight": "inherit",
       "overflow": "hidden",
       "overflow": "hidden",
       "position": "relative",
       "position": "relative",
       "width": "100%",
       "width": "100%",
@@ -23,8 +23,8 @@ exports[`CustomScrollbar renders correctly 1`] = `
         "left": undefined,
         "left": undefined,
         "marginBottom": 0,
         "marginBottom": 0,
         "marginRight": 0,
         "marginRight": 0,
-        "maxHeight": "calc(100% + 0px)",
-        "minHeight": "calc(100% + 0px)",
+        "maxHeight": "calc(inherit + 0px)",
+        "minHeight": "calc(inherit + 0px)",
         "overflow": "scroll",
         "overflow": "scroll",
         "position": "relative",
         "position": "relative",
         "right": undefined,
         "right": undefined,

+ 43 - 0
public/app/core/components/Form/Element.tsx

@@ -0,0 +1,43 @@
+import React, { PureComponent, ReactNode, ReactElement } from 'react';
+import { Label } from './Label';
+import { uniqueId } from 'lodash';
+
+interface Props {
+  label?: ReactNode;
+  labelClassName?: string;
+  id?: string;
+  children: ReactElement<any>;
+}
+
+export class Element extends PureComponent<Props> {
+  elementId: string = this.props.id || uniqueId('form-element-');
+
+  get elementLabel() {
+    const { label, labelClassName } = this.props;
+
+    if (label) {
+      return (
+        <Label htmlFor={this.elementId} className={labelClassName}>
+          {label}
+        </Label>
+      );
+    }
+
+    return null;
+  }
+
+  get children() {
+    const { children } = this.props;
+
+    return React.cloneElement(children, { id: this.elementId });
+  }
+
+  render() {
+    return (
+      <div className="our-custom-wrapper-class">
+        {this.elementLabel}
+        {this.children}
+      </div>
+    );
+  }
+}

+ 53 - 0
public/app/core/components/Form/Input.test.tsx

@@ -0,0 +1,53 @@
+import React from 'react';
+import renderer from 'react-test-renderer';
+import { shallow } from 'enzyme';
+import { Input, EventsWithValidation } from './Input';
+import { ValidationEvents } from 'app/types';
+
+const TEST_ERROR_MESSAGE = 'Value must be empty or less than 3 chars';
+const testBlurValidation: ValidationEvents = {
+  [EventsWithValidation.onBlur]: [
+    {
+      rule: (value: string) => {
+        if (!value || value.length < 3) {
+          return true;
+        }
+        return false;
+      },
+      errorMessage: TEST_ERROR_MESSAGE,
+    },
+  ],
+};
+
+describe('Input', () => {
+  it('renders correctly', () => {
+    const tree = renderer.create(<Input />).toJSON();
+    expect(tree).toMatchSnapshot();
+  });
+
+  it('should validate with error onBlur', () => {
+    const wrapper = shallow(<Input validationEvents={testBlurValidation} />);
+    const evt = {
+      persist: jest.fn,
+      target: {
+        value: 'I can not be more than 2 chars',
+      },
+    };
+
+    wrapper.find('input').simulate('blur', evt);
+    expect(wrapper.state('error')).toBe(TEST_ERROR_MESSAGE);
+  });
+
+  it('should validate without error onBlur', () => {
+    const wrapper = shallow(<Input validationEvents={testBlurValidation} />);
+    const evt = {
+      persist: jest.fn,
+      target: {
+        value: 'Hi',
+      },
+    };
+
+    wrapper.find('input').simulate('blur', evt);
+    expect(wrapper.state('error')).toBe(null);
+  });
+});

+ 94 - 0
public/app/core/components/Form/Input.tsx

@@ -0,0 +1,94 @@
+import React, { PureComponent } from 'react';
+import classNames from 'classnames';
+import { ValidationEvents, ValidationRule } from 'app/types';
+import { validate, hasValidationEvent } from 'app/core/utils/validate';
+
+export enum InputStatus {
+  Invalid = 'invalid',
+  Valid = 'valid',
+}
+
+export enum InputTypes {
+  Text = 'text',
+  Number = 'number',
+  Password = 'password',
+  Email = 'email',
+}
+
+export enum EventsWithValidation {
+  onBlur = 'onBlur',
+  onFocus = 'onFocus',
+  onChange = 'onChange',
+}
+
+interface Props extends React.HTMLProps<HTMLInputElement> {
+  validationEvents?: ValidationEvents;
+  hideErrorMessage?: boolean;
+
+  // Override event props and append status as argument
+  onBlur?: (event: React.FocusEvent<HTMLInputElement>, status?: InputStatus) => void;
+  onFocus?: (event: React.FocusEvent<HTMLInputElement>, status?: InputStatus) => void;
+  onChange?: (event: React.FormEvent<HTMLInputElement>, status?: InputStatus) => void;
+}
+
+export class Input extends PureComponent<Props> {
+  static defaultProps = {
+    className: '',
+  };
+
+  state = {
+    error: null,
+  };
+
+  get status() {
+    return this.state.error ? InputStatus.Invalid : InputStatus.Valid;
+  }
+
+  get isInvalid() {
+    return this.status === InputStatus.Invalid;
+  }
+
+  validatorAsync = (validationRules: ValidationRule[]) => {
+    return evt => {
+      const errors = validate(evt.target.value, validationRules);
+      this.setState(prevState => {
+        return {
+          ...prevState,
+          error: errors ? errors[0] : null,
+        };
+      });
+    };
+  };
+
+  populateEventPropsWithStatus = (restProps, validationEvents: ValidationEvents) => {
+    const inputElementProps = { ...restProps };
+    Object.keys(EventsWithValidation).forEach((eventName: EventsWithValidation) => {
+      if (hasValidationEvent(eventName, validationEvents) || restProps[eventName]) {
+        inputElementProps[eventName] = async evt => {
+          evt.persist(); // Needed for async. https://reactjs.org/docs/events.html#event-pooling
+          if (hasValidationEvent(eventName, validationEvents)) {
+            await this.validatorAsync(validationEvents[eventName]).apply(this, [evt]);
+          }
+          if (restProps[eventName]) {
+            restProps[eventName].apply(null, [evt, this.status]);
+          }
+        };
+      }
+    });
+    return inputElementProps;
+  };
+
+  render() {
+    const { validationEvents, className, hideErrorMessage, ...restProps } = this.props;
+    const { error } = this.state;
+    const inputClassName = classNames('gf-form-input', { invalid: this.isInvalid }, className);
+    const inputElementProps = this.populateEventPropsWithStatus(restProps, validationEvents);
+
+    return (
+      <div className="our-custom-wrapper-class">
+        <input {...inputElementProps} className={inputClassName} />
+        {error && !hideErrorMessage && <span>{error}</span>}
+      </div>
+    );
+  }
+}

+ 19 - 0
public/app/core/components/Form/Label.tsx

@@ -0,0 +1,19 @@
+import React, { PureComponent, ReactNode } from 'react';
+
+interface Props {
+  children: ReactNode;
+  htmlFor?: string;
+  className?: string;
+}
+
+export class Label extends PureComponent<Props> {
+  render() {
+    const { children, htmlFor, className } = this.props;
+
+    return (
+      <label className={`custom-label-class ${className || ''}`} htmlFor={htmlFor}>
+        {children}
+      </label>
+    );
+  }
+}

+ 11 - 0
public/app/core/components/Form/__snapshots__/Input.test.tsx.snap

@@ -0,0 +1,11 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Input renders correctly 1`] = `
+<div
+  className="our-custom-wrapper-class"
+>
+  <input
+    className="gf-form-input"
+  />
+</div>
+`;

+ 3 - 0
public/app/core/components/Form/index.ts

@@ -0,0 +1,3 @@
+export { Element } from './Element';
+export { Input } from './Input';
+export { Label } from './Label';

+ 51 - 0
public/app/core/components/JSONFormatter/JSONFormatter.tsx

@@ -0,0 +1,51 @@
+import React, { PureComponent, createRef } from 'react';
+// import JSONFormatterJS, { JSONFormatterConfiguration } from 'json-formatter-js';
+import { JsonExplorer } from 'app/core/core'; // We have made some monkey-patching of json-formatter-js so we can't switch right now
+
+interface Props {
+  className?: string;
+  json: {};
+  config?: any;
+  open?: number;
+  onDidRender?: (formattedJson: any) => void;
+}
+
+export class JSONFormatter extends PureComponent<Props> {
+  private wrapperRef = createRef<HTMLDivElement>();
+
+  static defaultProps = {
+    open: 3,
+    config: {
+      animateOpen: true,
+    },
+  };
+
+  componentDidMount() {
+    this.renderJson();
+  }
+
+  componentDidUpdate() {
+    this.renderJson();
+  }
+
+  renderJson = () => {
+    const { json, config, open, onDidRender } = this.props;
+    const wrapperEl = this.wrapperRef.current;
+    const formatter = new JsonExplorer(json, open, config);
+    const hasChildren: boolean = wrapperEl.hasChildNodes();
+    if (hasChildren) {
+      wrapperEl.replaceChild(formatter.render(), wrapperEl.lastChild);
+    } else {
+      wrapperEl.appendChild(formatter.render());
+    }
+
+    if (onDidRender) {
+      onDidRender(formatter.json);
+    }
+  };
+
+  render() {
+    const { className } = this.props;
+    return <div className={className} ref={this.wrapperRef} />;
+  }
+}

+ 1 - 0
public/app/core/components/Label/Label.tsx

@@ -6,6 +6,7 @@ interface Props {
   for?: string;
   for?: string;
   children: ReactNode;
   children: ReactNode;
   width?: number;
   width?: number;
+  className?: string;
 }
 }
 
 
 export const Label: SFC<Props> = props => {
 export const Label: SFC<Props> = props => {

+ 9 - 9
public/app/core/components/PermissionList/AddPermission.tsx

@@ -1,7 +1,7 @@
 import React, { Component } from 'react';
 import React, { Component } from 'react';
-import { UserPicker } from 'app/core/components/Picker/UserPicker';
-import { TeamPicker, Team } from 'app/core/components/Picker/TeamPicker';
-import DescriptionPicker, { OptionWithDescription } from 'app/core/components/Picker/DescriptionPicker';
+import { UserPicker } from 'app/core/components/Select/UserPicker';
+import { TeamPicker, Team } from 'app/core/components/Select/TeamPicker';
+import { Select, SelectOptionItem } from 'app/core/components/Select/Select';
 import { User } from 'app/types';
 import { User } from 'app/types';
 import {
 import {
   dashboardPermissionLevels,
   dashboardPermissionLevels,
@@ -61,7 +61,7 @@ class AddPermissions extends Component<Props, NewDashboardAclItem> {
     this.setState({ teamId: team && !Array.isArray(team) ? team.id : 0 });
     this.setState({ teamId: team && !Array.isArray(team) ? team.id : 0 });
   };
   };
 
 
-  onPermissionChanged = (permission: OptionWithDescription) => {
+  onPermissionChanged = (permission: SelectOptionItem) => {
     this.setState({ permission: permission.value });
     this.setState({ permission: permission.value });
   };
   };
 
 
@@ -121,11 +121,11 @@ class AddPermissions extends Component<Props, NewDashboardAclItem> {
             ) : null}
             ) : null}
 
 
             <div className="gf-form">
             <div className="gf-form">
-              <DescriptionPicker
-                optionsWithDesc={dashboardPermissionLevels}
-                onSelected={this.onPermissionChanged}
-                disabled={false}
-                className={'gf-form-select-box__control--menu-right'}
+              <Select
+                isSearchable={false}
+                options={dashboardPermissionLevels}
+                onChange={this.onPermissionChanged}
+                className="gf-form-select-box__control--menu-right"
               />
               />
             </div>
             </div>
 
 

+ 8 - 7
public/app/core/components/PermissionList/DisabledPermissionListItem.tsx

@@ -1,5 +1,5 @@
 import React, { Component } from 'react';
 import React, { Component } from 'react';
-import DescriptionPicker from 'app/core/components/Picker/DescriptionPicker';
+import Select from 'app/core/components/Select/Select';
 import { dashboardPermissionLevels } from 'app/types/acl';
 import { dashboardPermissionLevels } from 'app/types/acl';
 
 
 export interface Props {
 export interface Props {
@@ -9,6 +9,7 @@ export interface Props {
 export default class DisabledPermissionListItem extends Component<Props, any> {
 export default class DisabledPermissionListItem extends Component<Props, any> {
   render() {
   render() {
     const { item } = this.props;
     const { item } = this.props;
+    const currentPermissionLevel = dashboardPermissionLevels.find(dp => dp.value === item.permission);
 
 
     return (
     return (
       <tr className="gf-form-disabled">
       <tr className="gf-form-disabled">
@@ -23,12 +24,12 @@ export default class DisabledPermissionListItem extends Component<Props, any> {
         <td className="query-keyword">Can</td>
         <td className="query-keyword">Can</td>
         <td>
         <td>
           <div className="gf-form">
           <div className="gf-form">
-            <DescriptionPicker
-              optionsWithDesc={dashboardPermissionLevels}
-              onSelected={() => {}}
-              disabled={true}
-              className={'gf-form-select-box__control--menu-right'}
-              value={item.permission}
+            <Select
+              options={dashboardPermissionLevels}
+              onChange={() => {}}
+              isDisabled={true}
+              className="gf-form-select-box__control--menu-right"
+              value={currentPermissionLevel}
             />
             />
           </div>
           </div>
         </td>
         </td>

+ 9 - 7
public/app/core/components/PermissionList/PermissionListItem.tsx

@@ -1,5 +1,5 @@
 import React, { PureComponent } from 'react';
 import React, { PureComponent } from 'react';
-import DescriptionPicker from 'app/core/components/Picker/DescriptionPicker';
+import { Select } from 'app/core/components/Select/Select';
 import { dashboardPermissionLevels, DashboardAcl, PermissionLevel } from 'app/types/acl';
 import { dashboardPermissionLevels, DashboardAcl, PermissionLevel } from 'app/types/acl';
 import { FolderInfo } from 'app/types';
 import { FolderInfo } from 'app/types';
 
 
@@ -50,6 +50,7 @@ export default class PermissionsListItem extends PureComponent<Props> {
   render() {
   render() {
     const { item, folderInfo } = this.props;
     const { item, folderInfo } = this.props;
     const inheritedFromRoot = item.dashboardId === -1 && !item.inherited;
     const inheritedFromRoot = item.dashboardId === -1 && !item.inherited;
+    const currentPermissionLevel = dashboardPermissionLevels.find(dp => dp.value === item.permission);
 
 
     return (
     return (
       <tr className={setClassNameHelper(item.inherited)}>
       <tr className={setClassNameHelper(item.inherited)}>
@@ -74,12 +75,13 @@ export default class PermissionsListItem extends PureComponent<Props> {
         <td className="query-keyword">Can</td>
         <td className="query-keyword">Can</td>
         <td>
         <td>
           <div className="gf-form">
           <div className="gf-form">
-            <DescriptionPicker
-              optionsWithDesc={dashboardPermissionLevels}
-              onSelected={this.onPermissionChanged}
-              disabled={item.inherited}
-              className={'gf-form-select-box__control--menu-right'}
-              value={item.permission}
+            <Select
+              isSearchable={false}
+              options={dashboardPermissionLevels}
+              onChange={this.onPermissionChanged}
+              isDisabled={item.inherited}
+              className="gf-form-select-box__control--menu-right"
+              value={currentPermissionLevel}
             />
             />
           </div>
           </div>
         </td>
         </td>

+ 0 - 25
public/app/core/components/Picker/DescriptionOption.tsx

@@ -1,25 +0,0 @@
-import React from 'react';
-import { components } from 'react-select';
-import { OptionProps } from 'react-select/lib/components/Option';
-
-// https://github.com/JedWatson/react-select/issues/3038
-interface ExtendedOptionProps extends OptionProps<any> {
-  data: any;
-}
-
-export const Option = (props: ExtendedOptionProps) => {
-  const { children, isSelected, data, className } = props;
-  return (
-    <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>
-      </div>
-    </components.Option>
-  );
-};
-
-export default Option;

+ 0 - 52
public/app/core/components/Picker/DescriptionPicker.tsx

@@ -1,52 +0,0 @@
-import React, { Component } from 'react';
-import Select from 'react-select';
-import DescriptionOption from './DescriptionOption';
-import IndicatorsContainer from './IndicatorsContainer';
-import ResetStyles from './ResetStyles';
-import NoOptionsMessage from './NoOptionsMessage';
-
-export interface OptionWithDescription {
-  value: any;
-  label: string;
-  description: string;
-}
-
-export interface Props {
-  optionsWithDesc: OptionWithDescription[];
-  onSelected: (permission) => void;
-  disabled: boolean;
-  className?: string;
-  value?: any;
-}
-
-const getSelectedOption = (optionsWithDesc, value) => optionsWithDesc.find(option => option.value === value);
-
-class DescriptionPicker extends Component<Props, any> {
-  render() {
-    const { optionsWithDesc, onSelected, disabled, className, value } = this.props;
-    const selectedOption = getSelectedOption(optionsWithDesc, value);
-    return (
-      <div className="permissions-picker">
-        <Select
-          placeholder="Choose"
-          classNamePrefix={`gf-form-select-box`}
-          className={`width-7 gf-form-input gf-form-input--form-dropdown ${className || ''}`}
-          options={optionsWithDesc}
-          components={{
-            Option: DescriptionOption,
-            IndicatorsContainer,
-            NoOptionsMessage,
-          }}
-          styles={ResetStyles}
-          isDisabled={disabled}
-          onChange={onSelected}
-          getOptionValue={i => i.value}
-          getOptionLabel={i => i.label}
-          value={selectedOption}
-        />
-      </div>
-    );
-  }
-}
-
-export default DescriptionPicker;

+ 0 - 18
public/app/core/components/Picker/NoOptionsMessage.tsx

@@ -1,18 +0,0 @@
-import React from 'react';
-import { components } from 'react-select';
-import { OptionProps } from 'react-select/lib/components/Option';
-
-export interface Props {
-  children: Element;
-}
-
-export const PickerOption = (props: OptionProps<any>) => {
-  const { children, className } = props;
-  return (
-    <components.Option {...props}>
-      <div className={`description-picker-option__button btn btn-link ${className}`}>{children}</div>
-    </components.Option>
-  );
-};
-
-export default PickerOption;

+ 0 - 22
public/app/core/components/Picker/PickerOption.tsx

@@ -1,22 +0,0 @@
-import React from 'react';
-import { components } from 'react-select';
-import { OptionProps } from 'react-select/lib/components/Option';
-
-// https://github.com/JedWatson/react-select/issues/3038
-interface ExtendedOptionProps extends OptionProps<any> {
-  data: any;
-}
-
-export const PickerOption = (props: ExtendedOptionProps) => {
-  const { children, data, className } = props;
-  return (
-    <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>
-    </components.Option>
-  );
-};
-
-export default PickerOption;

+ 0 - 49
public/app/core/components/Picker/SimplePicker.tsx

@@ -1,49 +0,0 @@
-import React, { SFC } from 'react';
-import Select from 'react-select';
-import DescriptionOption from './DescriptionOption';
-import ResetStyles from './ResetStyles';
-
-interface Props {
-  className?: string;
-  defaultValue?: any;
-  getOptionLabel: (item: any) => string;
-  getOptionValue: (item: any) => string;
-  onSelected: (item: any) => {} | void;
-  options: any[];
-  placeholder?: string;
-  width: number;
-  value: any;
-}
-
-const SimplePicker: SFC<Props> = ({
-  className,
-  defaultValue,
-  getOptionLabel,
-  getOptionValue,
-  onSelected,
-  options,
-  placeholder,
-  width,
-  value,
-}) => {
-  return (
-    <Select
-      classNamePrefix={`gf-form-select-box`}
-      className={`width-${width} gf-form-input gf-form-input--form-dropdown ${className || ''}`}
-      components={{
-        Option: DescriptionOption,
-      }}
-      defaultValue={defaultValue}
-      value={value}
-      getOptionLabel={getOptionLabel}
-      getOptionValue={getOptionValue}
-      isSearchable={false}
-      onChange={onSelected}
-      options={options}
-      placeholder={placeholder || 'Choose'}
-      styles={ResetStyles}
-    />
-  );
-};
-
-export default SimplePicker;

+ 0 - 17
public/app/core/components/Picker/__snapshots__/PickerOption.test.tsx.snap

@@ -1,17 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`PickerOption renders correctly 1`] = `
-<div>
-  <div
-    className="description-picker-option__button btn btn-link class-for-user-picker"
-  >
-    <img
-      alt="User picker label"
-      className="user-picker-option__avatar"
-      src="url/to/avatar"
-    />
-    Model title
-  </div>
-</div>
-`;
-  

+ 35 - 0
public/app/core/components/Portal/Portal.tsx

@@ -0,0 +1,35 @@
+import { PureComponent } from 'react';
+import ReactDOM from 'react-dom';
+
+interface Props {
+  className?: string;
+  root?: HTMLElement;
+}
+
+export default class BodyPortal extends PureComponent<Props> {
+  node: HTMLElement = document.createElement('div');
+  portalRoot: HTMLElement;
+
+  constructor(props) {
+    super(props);
+    const {
+      className,
+      root = document.body
+    } = this.props;
+
+    if (className) {
+      this.node.classList.add(className);
+    }
+
+    this.portalRoot = root;
+    this.portalRoot.appendChild(this.node);
+  }
+
+  componentWillUnmount() {
+    this.portalRoot.removeChild(this.node);
+  }
+
+  render() {
+    return ReactDOM.createPortal(this.props.children, this.node);
+  }
+}

+ 72 - 0
public/app/core/components/Select/DataSourcePicker.tsx

@@ -0,0 +1,72 @@
+// Libraries
+import React, { PureComponent } from 'react';
+import _ from 'lodash';
+
+// Components
+import Select from './Select';
+
+// Types
+import { DataSourceSelectItem } from 'app/types';
+
+export interface Props {
+  onChange: (ds: DataSourceSelectItem) => void;
+  datasources: DataSourceSelectItem[];
+  current: DataSourceSelectItem;
+  onBlur?: () => void;
+  autoFocus?: boolean;
+}
+
+export class DataSourcePicker extends PureComponent<Props> {
+  static defaultProps = {
+    autoFocus: false,
+  };
+
+  searchInput: HTMLElement;
+
+  constructor(props) {
+    super(props);
+  }
+
+  onChange = item => {
+    const ds = this.props.datasources.find(ds => ds.name === item.value);
+    this.props.onChange(ds);
+  };
+
+  render() {
+    const { datasources, current, autoFocus, onBlur } = this.props;
+
+    const options = datasources.map(ds => ({
+      value: ds.name,
+      label: ds.name,
+      imgUrl: ds.meta.info.logos.small,
+    }));
+
+    const value = current && {
+      label: current.name,
+      value: current.name,
+      imgUrl: current.meta.info.logos.small,
+    };
+
+    return (
+      <div className="gf-form-inline">
+        <Select
+          className="ds-picker"
+          isMulti={false}
+          isClearable={false}
+          backspaceRemovesValue={false}
+          onChange={this.onChange}
+          options={options}
+          autoFocus={autoFocus}
+          onBlur={onBlur}
+          openMenuOnFocus={true}
+          maxMenuHeight={500}
+          placeholder="Select datasource"
+          noOptionsMessage={() => 'No datasources found'}
+          value={value}
+        />
+      </div>
+    );
+  }
+}
+
+export default DataSourcePicker;

+ 2 - 2
public/app/core/components/Picker/IndicatorsContainer.tsx → public/app/core/components/Select/IndicatorsContainer.tsx

@@ -1,5 +1,5 @@
-import React from 'react';
-import { components } from 'react-select';
+import React from 'react';
+import { components } from '@torkelo/react-select';
 
 
 export const IndicatorsContainer = props => {
 export const IndicatorsContainer = props => {
   const isOpen = props.selectProps.menuIsOpen;
   const isOpen = props.selectProps.menuIsOpen;

+ 20 - 0
public/app/core/components/Select/NoOptionsMessage.tsx

@@ -0,0 +1,20 @@
+import React from 'react';
+import { components } from '@torkelo/react-select';
+import { OptionProps } from '@torkelo/react-select/lib/components/Option';
+
+export interface Props {
+  children: Element;
+}
+
+export const NoOptionsMessage = (props: OptionProps<any>) => {
+  const { children } = props;
+  return (
+    <components.Option {...props}>
+      <div className="gf-form-select-box__desc-option">
+        <div className="gf-form-select-box__desc-option__body">{children}</div>
+      </div>
+    </components.Option>
+  );
+};
+
+export default NoOptionsMessage;

+ 53 - 0
public/app/core/components/Select/OptionGroup.tsx

@@ -0,0 +1,53 @@
+import React, { PureComponent } from 'react';
+import { GroupProps } from 'react-select/lib/components/Group';
+
+interface ExtendedGroupProps extends GroupProps<any> {
+  data: any;
+}
+
+interface State {
+  expanded: boolean;
+}
+
+export default class OptionGroup extends PureComponent<ExtendedGroupProps, State> {
+  state = {
+    expanded: false,
+  };
+
+  componentDidMount() {
+    if (this.props.selectProps) {
+      const value = this.props.selectProps.value[this.props.selectProps.value.length - 1];
+
+      if (value && this.props.options.some(option => option.value === value)) {
+        this.setState({ expanded: true });
+      }
+    }
+  }
+
+  componentDidUpdate(nextProps) {
+    if (nextProps.selectProps.inputValue !== '') {
+      this.setState({ expanded: true });
+    }
+  }
+
+  onToggleChildren = () => {
+    this.setState(prevState => ({
+      expanded: !prevState.expanded,
+    }));
+  };
+
+  render() {
+    const { children, label } = this.props;
+    const { expanded } = this.state;
+
+    return (
+      <div className="gf-form-select-box__option-group">
+        <div className="gf-form-select-box__option-group__header" onClick={this.onToggleChildren}>
+          <span className="flex-grow">{label}</span>
+          <i className={`fa ${expanded ? 'fa-caret-left' : 'fa-caret-down'}`} />{' '}
+        </div>
+        {expanded && children}
+      </div>
+    );
+  }
+}

+ 2 - 2
public/app/core/components/Picker/PickerOption.test.tsx → public/app/core/components/Select/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',

+ 44 - 0
public/app/core/components/Select/PickerOption.tsx

@@ -0,0 +1,44 @@
+import React from 'react';
+import { components } from '@torkelo/react-select';
+import { OptionProps } from 'react-select/lib/components/Option';
+
+// https://github.com/JedWatson/react-select/issues/3038
+interface ExtendedOptionProps extends OptionProps<any> {
+  data: {
+    description?: string;
+    imgUrl?: string;
+  };
+}
+
+export const Option = (props: ExtendedOptionProps) => {
+  const { children, isSelected, data } = props;
+
+  return (
+    <components.Option {...props}>
+      <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>
+    </components.Option>
+  );
+};
+
+// 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;

+ 4 - 2
public/app/core/components/Picker/ResetStyles.tsx → public/app/core/components/Select/ResetStyles.tsx

@@ -1,4 +1,4 @@
-export default {
+export default {
   clearIndicator: () => ({}),
   clearIndicator: () => ({}),
   container: () => ({}),
   container: () => ({}),
   control: () => ({}),
   control: () => ({}),
@@ -11,7 +11,9 @@
   loadingIndicator: () => ({}),
   loadingIndicator: () => ({}),
   loadingMessage: () => ({}),
   loadingMessage: () => ({}),
   menu: () => ({}),
   menu: () => ({}),
-  menuList: () => ({}),
+  menuList: ({ maxHeight }: { maxHeight: number }) => ({
+    maxHeight,
+  }),
   multiValue: () => ({}),
   multiValue: () => ({}),
   multiValueLabel: () => ({}),
   multiValueLabel: () => ({}),
   multiValueRemove: () => ({}),
   multiValueRemove: () => ({}),

+ 232 - 0
public/app/core/components/Select/Select.tsx

@@ -0,0 +1,232 @@
+// Libraries
+import classNames from 'classnames';
+import React, { PureComponent } from 'react';
+import { default as ReactSelect } from '@torkelo/react-select';
+import { default as ReactAsyncSelect } from '@torkelo/react-select/lib/Async';
+import { components } from '@torkelo/react-select';
+
+// Components
+import { Option, SingleValue } from './PickerOption';
+import OptionGroup from './OptionGroup';
+import IndicatorsContainer from './IndicatorsContainer';
+import NoOptionsMessage from './NoOptionsMessage';
+import ResetStyles from './ResetStyles';
+import CustomScrollbar from '../CustomScrollbar/CustomScrollbar';
+
+export interface SelectOptionItem {
+  label?: string;
+  value?: any;
+  imgUrl?: string;
+  description?: string;
+  [key: string]: any;
+}
+
+interface CommonProps {
+  defaultValue?: any;
+  getOptionLabel?: (item: SelectOptionItem) => string;
+  getOptionValue?: (item: SelectOptionItem) => string;
+  onChange: (item: SelectOptionItem) => {} | void;
+  placeholder?: string;
+  width?: number;
+  value?: SelectOptionItem;
+  className?: string;
+  isDisabled?: boolean;
+  isSearchable?: boolean;
+  isClearable?: boolean;
+  autoFocus?: boolean;
+  openMenuOnFocus?: boolean;
+  onBlur?: () => void;
+  maxMenuHeight?: number;
+  isLoading: boolean;
+  noOptionsMessage?: () => string;
+  isMulti?: boolean;
+  backspaceRemovesValue: boolean;
+}
+
+interface SelectProps {
+  options: SelectOptionItem[];
+}
+
+interface AsyncProps {
+  defaultOptions: boolean;
+  loadOptions: (query: string) => Promise<SelectOptionItem[]>;
+  loadingMessage?: () => string;
+}
+
+export const MenuList = props => {
+  return (
+    <components.MenuList {...props}>
+      <CustomScrollbar autoHide={false}>{props.children}</CustomScrollbar>
+    </components.MenuList>
+  );
+};
+
+export class Select extends PureComponent<CommonProps & SelectProps> {
+  static defaultProps = {
+    width: null,
+    className: '',
+    isDisabled: false,
+    isSearchable: true,
+    isClearable: false,
+    isMulti: false,
+    openMenuOnFocus: false,
+    autoFocus: false,
+    isLoading: false,
+    backspaceRemovesValue: true,
+    maxMenuHeight: 300,
+  };
+
+  render() {
+    const {
+      defaultValue,
+      getOptionLabel,
+      getOptionValue,
+      onChange,
+      options,
+      placeholder,
+      width,
+      value,
+      className,
+      isDisabled,
+      isLoading,
+      isSearchable,
+      isClearable,
+      backspaceRemovesValue,
+      isMulti,
+      autoFocus,
+      openMenuOnFocus,
+      onBlur,
+      maxMenuHeight,
+      noOptionsMessage,
+    } = this.props;
+
+    let widthClass = '';
+    if (width) {
+      widthClass = 'width-' + width;
+    }
+
+    const selectClassNames = classNames('gf-form-input', 'gf-form-input--form-dropdown', widthClass, className);
+
+    return (
+      <ReactSelect
+        classNamePrefix="gf-form-select-box"
+        className={selectClassNames}
+        components={{
+          Option,
+          SingleValue,
+          IndicatorsContainer,
+          MenuList,
+          Group: OptionGroup,
+        }}
+        defaultValue={defaultValue}
+        value={value}
+        getOptionLabel={getOptionLabel}
+        getOptionValue={getOptionValue}
+        menuShouldScrollIntoView={false}
+        isSearchable={isSearchable}
+        onChange={onChange}
+        options={options}
+        placeholder={placeholder || 'Choose'}
+        styles={ResetStyles}
+        isDisabled={isDisabled}
+        isLoading={isLoading}
+        isClearable={isClearable}
+        autoFocus={autoFocus}
+        onBlur={onBlur}
+        openMenuOnFocus={openMenuOnFocus}
+        maxMenuHeight={maxMenuHeight}
+        noOptionsMessage={noOptionsMessage}
+        isMulti={isMulti}
+        backspaceRemovesValue={backspaceRemovesValue}
+      />
+    );
+  }
+}
+
+export class AsyncSelect extends PureComponent<CommonProps & AsyncProps> {
+  static defaultProps = {
+    width: null,
+    className: '',
+    components: {},
+    loadingMessage: () => 'Loading...',
+    isDisabled: false,
+    isClearable: false,
+    isMulti: false,
+    isSearchable: true,
+    backspaceRemovesValue: true,
+    autoFocus: false,
+    openMenuOnFocus: false,
+    maxMenuHeight: 300,
+  };
+
+  render() {
+    const {
+      defaultValue,
+      getOptionLabel,
+      getOptionValue,
+      onChange,
+      placeholder,
+      width,
+      value,
+      className,
+      loadOptions,
+      defaultOptions,
+      isLoading,
+      loadingMessage,
+      noOptionsMessage,
+      isDisabled,
+      isSearchable,
+      isClearable,
+      backspaceRemovesValue,
+      autoFocus,
+      onBlur,
+      openMenuOnFocus,
+      maxMenuHeight,
+      isMulti,
+    } = this.props;
+
+    let widthClass = '';
+    if (width) {
+      widthClass = 'width-' + width;
+    }
+
+    const selectClassNames = classNames('gf-form-input', 'gf-form-input--form-dropdown', widthClass, className);
+
+    return (
+      <ReactAsyncSelect
+        classNamePrefix="gf-form-select-box"
+        className={selectClassNames}
+        components={{
+          Option,
+          SingleValue,
+          IndicatorsContainer,
+          NoOptionsMessage,
+        }}
+        defaultValue={defaultValue}
+        value={value}
+        getOptionLabel={getOptionLabel}
+        getOptionValue={getOptionValue}
+        menuShouldScrollIntoView={false}
+        onChange={onChange}
+        loadOptions={loadOptions}
+        isLoading={isLoading}
+        defaultOptions={defaultOptions}
+        placeholder={placeholder || 'Choose'}
+        styles={ResetStyles}
+        loadingMessage={loadingMessage}
+        noOptionsMessage={noOptionsMessage}
+        isDisabled={isDisabled}
+        isSearchable={isSearchable}
+        isClearable={isClearable}
+        autoFocus={autoFocus}
+        onBlur={onBlur}
+        openMenuOnFocus={openMenuOnFocus}
+        maxMenuHeight={maxMenuHeight}
+        isMulti={isMulti}
+        backspaceRemovesValue={backspaceRemovesValue}
+      />
+    );
+  }
+}
+
+export default Select;

+ 0 - 0
public/app/core/components/Picker/TeamPicker.test.tsx → public/app/core/components/Select/TeamPicker.test.tsx


+ 4 - 18
public/app/core/components/Picker/TeamPicker.tsx → public/app/core/components/Select/TeamPicker.tsx

@@ -1,11 +1,7 @@
 import React, { Component } from 'react';
 import React, { Component } from 'react';
-import AsyncSelect from 'react-select/lib/Async';
-import PickerOption from './PickerOption';
+import { AsyncSelect } from './Select';
 import { debounce } from 'lodash';
 import { debounce } from 'lodash';
 import { getBackendSrv } from 'app/core/services/backend_srv';
 import { getBackendSrv } from 'app/core/services/backend_srv';
-import ResetStyles from './ResetStyles';
-import IndicatorsContainer from './IndicatorsContainer';
-import NoOptionsMessage from './NoOptionsMessage';
 
 
 export interface Team {
 export interface Team {
   id: number;
   id: number;
@@ -45,9 +41,10 @@ export class TeamPicker extends Component<Props, State> {
       const teams = result.teams.map(team => {
       const teams = result.teams.map(team => {
         return {
         return {
           id: team.id,
           id: team.id,
+          value: team.id,
           label: team.name,
           label: team.name,
           name: team.name,
           name: team.name,
-          avatarUrl: team.avatarUrl,
+          imgUrl: team.avatarUrl,
         };
         };
       });
       });
 
 
@@ -62,24 +59,13 @@ export class TeamPicker extends Component<Props, State> {
     return (
     return (
       <div className="user-picker">
       <div className="user-picker">
         <AsyncSelect
         <AsyncSelect
-          classNamePrefix={`gf-form-select-box`}
-          isMulti={false}
           isLoading={isLoading}
           isLoading={isLoading}
           defaultOptions={true}
           defaultOptions={true}
           loadOptions={this.debouncedSearch}
           loadOptions={this.debouncedSearch}
           onChange={onSelected}
           onChange={onSelected}
-          className={`gf-form-input gf-form-input--form-dropdown ${className || ''}`}
-          styles={ResetStyles}
-          components={{
-            Option: PickerOption,
-            IndicatorsContainer,
-            NoOptionsMessage,
-          }}
+          className={className}
           placeholder="Select a team"
           placeholder="Select a team"
-          loadingMessage={() => 'Loading...'}
           noOptionsMessage={() => 'No teams found'}
           noOptionsMessage={() => 'No teams found'}
-          getOptionValue={i => i.id}
-          getOptionLabel={i => i.label}
         />
         />
       </div>
       </div>
     );
     );

+ 51 - 0
public/app/core/components/Select/UnitPicker.tsx

@@ -0,0 +1,51 @@
+import React, { PureComponent } from 'react';
+import Select from './Select';
+import kbn from 'app/core/utils/kbn';
+
+interface Props {
+  onChange: (item: any) => {} | void;
+  defaultValue?: string;
+  width?: number;
+}
+
+export default class UnitPicker extends PureComponent<Props> {
+  static defaultProps = {
+    width: 12,
+  };
+
+  render() {
+    const { defaultValue, onChange, width } = this.props;
+
+    const unitGroups = kbn.getUnitFormats();
+
+    // Need to transform the data structure to work well with Select
+    const groupOptions = unitGroups.map(group => {
+      const options = group.submenu.map(unit => {
+        return {
+          label: unit.text,
+          value: unit.value,
+        };
+      });
+
+      return {
+        label: group.text,
+        options,
+      };
+    });
+
+    const value = groupOptions.map(group => {
+      return group.options.find(option => option.value === defaultValue);
+    });
+
+    return (
+      <Select
+        width={width}
+        defaultValue={value}
+        isSearchable={true}
+        options={groupOptions}
+        placeholder="Choose"
+        onChange={onChange}
+      />
+    );
+  }
+}

+ 0 - 0
public/app/core/components/Picker/UserPicker.test.tsx → public/app/core/components/Select/UserPicker.test.tsx


+ 11 - 18
public/app/core/components/Picker/UserPicker.tsx → public/app/core/components/Select/UserPicker.tsx

@@ -1,12 +1,15 @@
+// Libraries
 import React, { Component } from 'react';
 import React, { Component } from 'react';
-import AsyncSelect from 'react-select/lib/Async';
-import PickerOption from './PickerOption';
+
+// Components
+import { AsyncSelect } from './Select';
+
+// Utils & Services
 import { debounce } from 'lodash';
 import { debounce } from 'lodash';
 import { getBackendSrv } from 'app/core/services/backend_srv';
 import { getBackendSrv } from 'app/core/services/backend_srv';
+
+// Types
 import { User } from 'app/types';
 import { User } from 'app/types';
-import ResetStyles from './ResetStyles';
-import IndicatorsContainer from './IndicatorsContainer';
-import NoOptionsMessage from './NoOptionsMessage';
 
 
 export interface Props {
 export interface Props {
   onSelected: (user: User) => void;
   onSelected: (user: User) => void;
@@ -40,8 +43,9 @@ export class UserPicker extends Component<Props, State> {
       .then(result => {
       .then(result => {
         return result.map(user => ({
         return result.map(user => ({
           id: user.userId,
           id: user.userId,
+          value: 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,
         }));
         }));
       })
       })
@@ -57,24 +61,13 @@ export class UserPicker extends Component<Props, State> {
     return (
     return (
       <div className="user-picker">
       <div className="user-picker">
         <AsyncSelect
         <AsyncSelect
-          classNamePrefix={`gf-form-select-box`}
-          isMulti={false}
+          className={className}
           isLoading={isLoading}
           isLoading={isLoading}
           defaultOptions={true}
           defaultOptions={true}
           loadOptions={this.debouncedSearch}
           loadOptions={this.debouncedSearch}
           onChange={onSelected}
           onChange={onSelected}
-          className={`gf-form-input gf-form-input--form-dropdown ${className || ''}`}
-          styles={ResetStyles}
-          components={{
-            Option: PickerOption,
-            IndicatorsContainer,
-            NoOptionsMessage,
-          }}
           placeholder="Select user"
           placeholder="Select user"
-          loadingMessage={() => 'Loading...'}
           noOptionsMessage={() => 'No users found'}
           noOptionsMessage={() => 'No users found'}
-          getOptionValue={i => i.id}
-          getOptionLabel={i => i.label}
         />
         />
       </div>
       </div>
     );
     );

+ 21 - 0
public/app/core/components/Select/__snapshots__/PickerOption.test.tsx.snap

@@ -0,0 +1,21 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`PickerOption renders correctly 1`] = `
+<div>
+  <div
+    className="gf-form-select-box__desc-option"
+  >
+    <img
+      className="gf-form-select-box__desc-option__img"
+      src="url/to/avatar"
+    />
+    <div
+      className="gf-form-select-box__desc-option__body"
+    >
+      <div>
+        Model title
+      </div>
+    </div>
+  </div>
+</div>
+`;

+ 0 - 29
public/app/core/components/Picker/__snapshots__/TeamPicker.test.tsx.snap → public/app/core/components/Select/__snapshots__/TeamPicker.test.tsx.snap

@@ -57,35 +57,6 @@ exports[`TeamPicker renders correctly 1`] = `
                 }
                 }
               }
               }
               tabIndex="0"
               tabIndex="0"
-              theme={
-                Object {
-                  "borderRadius": 4,
-                  "colors": Object {
-                    "danger": "#DE350B",
-                    "dangerLight": "#FFBDAD",
-                    "neutral0": "hsl(0, 0%, 100%)",
-                    "neutral10": "hsl(0, 0%, 90%)",
-                    "neutral20": "hsl(0, 0%, 80%)",
-                    "neutral30": "hsl(0, 0%, 70%)",
-                    "neutral40": "hsl(0, 0%, 60%)",
-                    "neutral5": "hsl(0, 0%, 95%)",
-                    "neutral50": "hsl(0, 0%, 50%)",
-                    "neutral60": "hsl(0, 0%, 40%)",
-                    "neutral70": "hsl(0, 0%, 30%)",
-                    "neutral80": "hsl(0, 0%, 20%)",
-                    "neutral90": "hsl(0, 0%, 10%)",
-                    "primary": "#2684FF",
-                    "primary25": "#DEEBFF",
-                    "primary50": "#B2D4FF",
-                    "primary75": "#4C9AFF",
-                  },
-                  "spacing": Object {
-                    "baseUnit": 4,
-                    "controlHeight": 38,
-                    "menuGutter": 8,
-                  },
-                }
-              }
               type="text"
               type="text"
               value=""
               value=""
             />
             />

+ 0 - 29
public/app/core/components/Picker/__snapshots__/UserPicker.test.tsx.snap → public/app/core/components/Select/__snapshots__/UserPicker.test.tsx.snap

@@ -57,35 +57,6 @@ exports[`UserPicker renders correctly 1`] = `
                 }
                 }
               }
               }
               tabIndex="0"
               tabIndex="0"
-              theme={
-                Object {
-                  "borderRadius": 4,
-                  "colors": Object {
-                    "danger": "#DE350B",
-                    "dangerLight": "#FFBDAD",
-                    "neutral0": "hsl(0, 0%, 100%)",
-                    "neutral10": "hsl(0, 0%, 90%)",
-                    "neutral20": "hsl(0, 0%, 80%)",
-                    "neutral30": "hsl(0, 0%, 70%)",
-                    "neutral40": "hsl(0, 0%, 60%)",
-                    "neutral5": "hsl(0, 0%, 95%)",
-                    "neutral50": "hsl(0, 0%, 50%)",
-                    "neutral60": "hsl(0, 0%, 40%)",
-                    "neutral70": "hsl(0, 0%, 30%)",
-                    "neutral80": "hsl(0, 0%, 20%)",
-                    "neutral90": "hsl(0, 0%, 10%)",
-                    "primary": "#2684FF",
-                    "primary25": "#DEEBFF",
-                    "primary50": "#B2D4FF",
-                    "primary75": "#4C9AFF",
-                  },
-                  "spacing": Object {
-                    "baseUnit": 4,
-                    "controlHeight": 38,
-                    "menuGutter": 8,
-                  },
-                }
-              }
               type="text"
               type="text"
               value=""
               value=""
             />
             />

+ 13 - 15
public/app/core/components/SharedPreferences/SharedPreferences.tsx

@@ -1,7 +1,7 @@
 import React, { PureComponent } from 'react';
 import React, { PureComponent } from 'react';
 
 
 import { Label } from 'app/core/components/Label/Label';
 import { Label } from 'app/core/components/Label/Label';
-import SimplePicker from 'app/core/components/Picker/SimplePicker';
+import Select from 'app/core/components/Select/Select';
 import { getBackendSrv, BackendSrv } from 'app/core/services/backend_srv';
 import { getBackendSrv, BackendSrv } from 'app/core/services/backend_srv';
 
 
 import { DashboardSearchHit } from 'app/types';
 import { DashboardSearchHit } from 'app/types';
@@ -17,12 +17,12 @@ export interface State {
   dashboards: DashboardSearchHit[];
   dashboards: DashboardSearchHit[];
 }
 }
 
 
-const themes = [{ value: '', text: 'Default' }, { value: 'dark', text: 'Dark' }, { value: 'light', text: 'Light' }];
+const themes = [{ value: '', label: 'Default' }, { value: 'dark', label: 'Dark' }, { value: 'light', label: 'Light' }];
 
 
 const timezones = [
 const timezones = [
-  { value: '', text: 'Default' },
-  { value: 'browser', text: 'Local browser time' },
-  { value: 'utc', text: 'UTC' },
+  { value: '', label: 'Default' },
+  { value: 'browser', label: 'Local browser time' },
+  { value: 'utc', label: 'UTC' },
 ];
 ];
 
 
 export class SharedPreferences extends PureComponent<Props, State> {
 export class SharedPreferences extends PureComponent<Props, State> {
@@ -91,12 +91,11 @@ export class SharedPreferences extends PureComponent<Props, State> {
         <h3 className="page-heading">Preferences</h3>
         <h3 className="page-heading">Preferences</h3>
         <div className="gf-form">
         <div className="gf-form">
           <span className="gf-form-label width-11">UI Theme</span>
           <span className="gf-form-label width-11">UI Theme</span>
-          <SimplePicker
+          <Select
+            isSearchable={false}
             value={themes.find(item => item.value === theme)}
             value={themes.find(item => item.value === theme)}
             options={themes}
             options={themes}
-            getOptionValue={i => i.value}
-            getOptionLabel={i => i.text}
-            onSelected={theme => this.onThemeChanged(theme.value)}
+            onChange={theme => this.onThemeChanged(theme.value)}
             width={20}
             width={20}
           />
           />
         </div>
         </div>
@@ -107,11 +106,11 @@ export class SharedPreferences extends PureComponent<Props, State> {
           >
           >
             Home Dashboard
             Home Dashboard
           </Label>
           </Label>
-          <SimplePicker
+          <Select
             value={dashboards.find(dashboard => dashboard.id === homeDashboardId)}
             value={dashboards.find(dashboard => dashboard.id === homeDashboardId)}
             getOptionValue={i => i.id}
             getOptionValue={i => i.id}
             getOptionLabel={i => i.title}
             getOptionLabel={i => i.title}
-            onSelected={(dashboard: DashboardSearchHit) => this.onHomeDashboardChanged(dashboard.id)}
+            onChange={(dashboard: DashboardSearchHit) => this.onHomeDashboardChanged(dashboard.id)}
             options={dashboards}
             options={dashboards}
             placeholder="Chose default dashboard"
             placeholder="Chose default dashboard"
             width={20}
             width={20}
@@ -119,11 +118,10 @@ export class SharedPreferences extends PureComponent<Props, State> {
         </div>
         </div>
         <div className="gf-form">
         <div className="gf-form">
           <label className="gf-form-label width-11">Timezone</label>
           <label className="gf-form-label width-11">Timezone</label>
-          <SimplePicker
+          <Select
+            isSearchable={false}
             value={timezones.find(item => item.value === timezone)}
             value={timezones.find(item => item.value === timezone)}
-            getOptionValue={i => i.value}
-            getOptionLabel={i => i.text}
-            onSelected={timezone => this.onTimeZoneChanged(timezone.value)}
+            onChange={timezone => this.onTimeZoneChanged(timezone.value)}
             options={timezones}
             options={timezones}
             width={20}
             width={20}
           />
           />

+ 9 - 16
public/app/core/components/Switch/Switch.tsx

@@ -5,8 +5,8 @@ export interface Props {
   label: string;
   label: string;
   checked: boolean;
   checked: boolean;
   labelClass?: string;
   labelClass?: string;
-  small?: boolean;
   switchClass?: string;
   switchClass?: string;
+  transparent?: boolean;
   onChange: (event) => any;
   onChange: (event) => any;
 }
 }
 
 
@@ -25,27 +25,20 @@ export class Switch extends PureComponent<Props, State> {
   };
   };
 
 
   render() {
   render() {
-    const { labelClass = '', switchClass = '', label, checked, small } = this.props;
+    const { labelClass = '', switchClass = '', label, checked, transparent } = this.props;
+
     const labelId = `check-${this.state.id}`;
     const labelId = `check-${this.state.id}`;
-    let labelClassName = `gf-form-label ${labelClass} pointer`;
-    let switchClassName = `gf-form-switch ${switchClass}`;
-    if (small) {
-      labelClassName += ' gf-form-label--small';
-      switchClassName += ' gf-form-switch--small';
-    }
+    const labelClassName = `gf-form-label ${labelClass} ${transparent ? 'gf-form-label--transparent' : ''} pointer`;
+    const switchClassName = `gf-form-switch ${switchClass} ${transparent ? 'gf-form-switch--transparent' : ''}`;
 
 
     return (
     return (
-      <div className="gf-form">
-        {label && (
-          <label htmlFor={labelId} className={labelClassName}>
-            {label}
-          </label>
-        )}
+      <label htmlFor={labelId} className="gf-form-switch-container">
+        {label && <div className={labelClassName}>{label}</div>}
         <div className={switchClassName}>
         <div className={switchClassName}>
           <input id={labelId} type="checkbox" checked={checked} onChange={this.internalOnChange} />
           <input id={labelId} type="checkbox" checked={checked} onChange={this.internalOnChange} />
-          <label htmlFor={labelId} />
+          <span className="gf-form-switch__slider" />
         </div>
         </div>
-      </div>
+      </label>
     );
     );
   }
   }
 }
 }

+ 6 - 5
public/app/core/components/TagFilter/TagFilter.tsx

@@ -1,11 +1,12 @@
 import React from 'react';
 import React from 'react';
-import AsyncSelect from 'react-select/lib/Async';
+import AsyncSelect from '@torkelo/react-select/lib/Async';
+
 import { TagOption } from './TagOption';
 import { TagOption } from './TagOption';
 import { TagBadge } from './TagBadge';
 import { TagBadge } from './TagBadge';
-import IndicatorsContainer from 'app/core/components/Picker/IndicatorsContainer';
-import NoOptionsMessage from 'app/core/components/Picker/NoOptionsMessage';
-import { components } from 'react-select';
-import ResetStyles from 'app/core/components/Picker/ResetStyles';
+import IndicatorsContainer from 'app/core/components/Select/IndicatorsContainer';
+import NoOptionsMessage from 'app/core/components/Select/NoOptionsMessage';
+import { components } from '@torkelo/react-select';
+import ResetStyles from 'app/core/components/Select/ResetStyles';
 
 
 export interface Props {
 export interface Props {
   tags: string[];
   tags: string[];

+ 1 - 1
public/app/core/components/TagFilter/TagOption.tsx

@@ -1,5 +1,5 @@
 import React from 'react';
 import React from 'react';
-import { components } from 'react-select';
+import { components } from '@torkelo/react-select';
 import { OptionProps } from 'react-select/lib/components/Option';
 import { OptionProps } from 'react-select/lib/components/Option';
 import { TagBadge } from './TagBadge';
 import { TagBadge } from './TagBadge';
 
 

+ 18 - 35
public/app/core/components/ToggleButtonGroup/ToggleButtonGroup.tsx

@@ -1,43 +1,20 @@
-import React, { SFC, ReactNode, PureComponent, ReactElement } from 'react';
+import React, { SFC, ReactNode, PureComponent } from 'react';
+import Tooltip from 'app/core/components/Tooltip/Tooltip';
 
 
 interface ToggleButtonGroupProps {
 interface ToggleButtonGroupProps {
-  onChange: (value) => void;
-  value?: any;
   label?: string;
   label?: string;
-  render: (props) => void;
+  children: JSX.Element[];
+  transparent?: boolean;
 }
 }
 
 
 export default class ToggleButtonGroup extends PureComponent<ToggleButtonGroupProps> {
 export default class ToggleButtonGroup extends PureComponent<ToggleButtonGroupProps> {
-  getValues() {
-    const { children } = this.props;
-    return React.Children.toArray(children).map((c: ReactElement<any>) => c.props.value);
-  }
-
-  smallChildren() {
-    const { children } = this.props;
-    return React.Children.toArray(children).every((c: ReactElement<any>) => c.props.className.includes('small'));
-  }
-
-  handleToggle(toggleValue) {
-    const { value, onChange } = this.props;
-    if (value && value === toggleValue) {
-      return;
-    }
-    onChange(toggleValue);
-  }
-
   render() {
   render() {
-    const { value, label } = this.props;
-    const values = this.getValues();
-    const selectedValue = value || values[0];
-    const labelClassName = `gf-form-label ${this.smallChildren() ? 'small' : ''}`;
+    const { children, label, transparent } = this.props;
 
 
     return (
     return (
       <div className="gf-form">
       <div className="gf-form">
-        <div className="toggle-button-group">
-          {label && <label className={labelClassName}>{label}</label>}
-          {this.props.render({ selectedValue, onChange: this.handleToggle.bind(this) })}
-        </div>
+        {label && <label className={`gf-form-label ${transparent ? 'gf-form-label--transparent' : ''}`}>{label}</label>}
+        <div className={`toggle-button-group ${transparent ? 'toggle-button-group--transparent' : ''}`}>{children}</div>
       </div>
       </div>
     );
     );
   }
   }
@@ -49,15 +26,15 @@ interface ToggleButtonProps {
   value: any;
   value: any;
   className?: string;
   className?: string;
   children: ReactNode;
   children: ReactNode;
-  title?: string;
+  tooltip?: string;
 }
 }
 
 
 export const ToggleButton: SFC<ToggleButtonProps> = ({
 export const ToggleButton: SFC<ToggleButtonProps> = ({
   children,
   children,
   selected,
   selected,
   className = '',
   className = '',
-  title = null,
-  value,
+  value = null,
+  tooltip,
   onChange,
   onChange,
 }) => {
 }) => {
   const handleChange = event => {
   const handleChange = event => {
@@ -68,9 +45,15 @@ export const ToggleButton: SFC<ToggleButtonProps> = ({
   };
   };
 
 
   const btnClassName = `btn ${className} ${selected ? 'active' : ''}`;
   const btnClassName = `btn ${className} ${selected ? 'active' : ''}`;
-  return (
-    <button className={btnClassName} onClick={handleChange} title={title}>
+  const button = (
+    <button className={btnClassName} onClick={handleChange}>
       <span>{children}</span>
       <span>{children}</span>
     </button>
     </button>
   );
   );
+
+  if (tooltip) {
+    return <Tooltip content={tooltip}>{button}</Tooltip>;
+  } else {
+    return button;
+  }
 };
 };

+ 11 - 26
public/app/core/components/Tooltip/Popover.tsx

@@ -1,34 +1,19 @@
-import React from 'react';
-import withTooltip from './withTooltip';
-import { Target } from 'react-popper';
+import React, { PureComponent } from 'react';
+import Popper from './Popper';
+import withPopper, { UsingPopperProps } from './withPopper';
 
 
-interface PopoverProps {
-  tooltipSetState: (prevState: object) => void;
-}
-
-class Popover extends React.Component<PopoverProps, any> {
-  constructor(props) {
-    super(props);
-    this.toggleTooltip = this.toggleTooltip.bind(this);
-  }
+class Popover extends PureComponent<UsingPopperProps> {
+  render() {
+    const { children, hidePopper, showPopper, className, ...restProps } = this.props;
 
 
-  toggleTooltip() {
-    const { tooltipSetState } = this.props;
-    tooltipSetState(prevState => {
-      return {
-        ...prevState,
-        show: !prevState.show,
-      };
-    });
-  }
+    const togglePopper = restProps.show ? hidePopper : showPopper;
 
 
-  render() {
     return (
     return (
-      <Target className="popper__target" onClick={this.toggleTooltip}>
-        {this.props.children}
-      </Target>
+      <div className={`popper__manager ${className}`} onClick={togglePopper}>
+        <Popper {...restProps}>{children}</Popper>
+      </div>
     );
     );
   }
   }
 }
 }
 
 
-export default withTooltip(Popover);
+export default withPopper(Popover);

+ 72 - 0
public/app/core/components/Tooltip/Popper.tsx

@@ -0,0 +1,72 @@
+import React, { PureComponent } from 'react';
+import Portal from 'app/core/components/Portal/Portal';
+import { Manager, Popper as ReactPopper, Reference } from 'react-popper';
+import Transition from 'react-transition-group/Transition';
+
+const defaultTransitionStyles = {
+  transition: 'opacity 200ms linear',
+  opacity: 0,
+};
+
+const transitionStyles = {
+  exited: { opacity: 0 },
+  entering: { opacity: 0 },
+  entered: { opacity: 1 },
+  exiting: { opacity: 0 },
+};
+
+interface Props {
+  renderContent: (content: any) => any;
+  show: boolean;
+  placement?: any;
+  content: string | ((props: any) => JSX.Element);
+  refClassName?: string;
+}
+
+class Popper extends PureComponent<Props> {
+  render() {
+    const { children, renderContent, show, placement, refClassName } = this.props;
+    const { content } = this.props;
+
+    return (
+      <Manager>
+        <Reference>
+          {({ ref }) => (
+            <div className={`popper_ref ${refClassName || ''}`} ref={ref}>
+              {children}
+            </div>
+          )}
+        </Reference>
+        <Transition in={show} timeout={100} mountOnEnter={true} unmountOnExit={true}>
+          {transitionState => (
+            <Portal>
+              <ReactPopper placement={placement}>
+                {({ ref, style, placement, arrowProps }) => {
+                  return (
+                    <div
+                      ref={ref}
+                      style={{
+                        ...style,
+                        ...defaultTransitionStyles,
+                        ...transitionStyles[transitionState],
+                      }}
+                      data-placement={placement}
+                      className="popper"
+                    >
+                      <div className="popper__background">
+                        {renderContent(content)}
+                        <div ref={arrowProps.ref} data-placement={placement} className="popper__arrow" />
+                      </div>
+                    </div>
+                  );
+                }}
+              </ReactPopper>
+            </Portal>
+          )}
+        </Transition>
+      </Manager>
+    );
+  }
+}
+
+export default Popper;

+ 9 - 28
public/app/core/components/Tooltip/Tooltip.tsx

@@ -1,36 +1,17 @@
 import React, { PureComponent } from 'react';
 import React, { PureComponent } from 'react';
-import withTooltip from './withTooltip';
-import { Target } from 'react-popper';
-
-interface Props {
-  tooltipSetState: (prevState: object) => void;
-}
-
-class Tooltip extends PureComponent<Props> {
-  showTooltip = () => {
-    const { tooltipSetState } = this.props;
-
-    tooltipSetState(prevState => ({
-      ...prevState,
-      show: true,
-    }));
-  };
-
-  hideTooltip = () => {
-    const { tooltipSetState } = this.props;
-    tooltipSetState(prevState => ({
-      ...prevState,
-      show: false,
-    }));
-  };
+import Popper from './Popper';
+import withPopper, { UsingPopperProps } from './withPopper';
 
 
+class Tooltip extends PureComponent<UsingPopperProps> {
   render() {
   render() {
+    const { children, hidePopper, showPopper, className, ...restProps } = this.props;
+
     return (
     return (
-      <Target className="popper__target" onMouseOver={this.showTooltip} onMouseOut={this.hideTooltip}>
-        {this.props.children}
-      </Target>
+      <div className={`popper__manager ${className}`} onMouseEnter={showPopper} onMouseLeave={hidePopper}>
+        <Popper {...restProps}>{children}</Popper>
+      </div>
     );
     );
   }
   }
 }
 }
 
 
-export default withTooltip(Tooltip);
+export default withPopper(Tooltip);

+ 2 - 2
public/app/core/components/Tooltip/__snapshots__/Popover.test.tsx.snap

@@ -3,10 +3,10 @@
 exports[`Popover renders correctly 1`] = `
 exports[`Popover renders correctly 1`] = `
 <div
 <div
   className="popper__manager test-class"
   className="popper__manager test-class"
+  onClick={[Function]}
 >
 >
   <div
   <div
-    className="popper__target"
-    onClick={[Function]}
+    className="popper_ref "
   >
   >
     <button>
     <button>
       Button with Popover
       Button with Popover

+ 3 - 3
public/app/core/components/Tooltip/__snapshots__/Tooltip.test.tsx.snap

@@ -3,11 +3,11 @@
 exports[`Tooltip renders correctly 1`] = `
 exports[`Tooltip renders correctly 1`] = `
 <div
 <div
   className="popper__manager test-class"
   className="popper__manager test-class"
+  onMouseEnter={[Function]}
+  onMouseLeave={[Function]}
 >
 >
   <div
   <div
-    className="popper__target"
-    onMouseOut={[Function]}
-    onMouseOver={[Function]}
+    className="popper_ref "
   >
   >
     <a
     <a
       href="http://www.grafana.com"
       href="http://www.grafana.com"

+ 88 - 0
public/app/core/components/Tooltip/withPopper.tsx

@@ -0,0 +1,88 @@
+import React from 'react';
+
+export interface UsingPopperProps {
+  showPopper: (prevState: object) => void;
+  hidePopper: (prevState: object) => void;
+  renderContent: (content: any) => any;
+  show: boolean;
+  placement?: string;
+  content: string | ((props: any) => JSX.Element);
+  className?: string;
+  refClassName?: string;
+}
+
+interface Props {
+  placement?: string;
+  className?: string;
+  refClassName?: string;
+  content: string | ((props: any) => JSX.Element);
+}
+
+interface State {
+  placement: string;
+  show: boolean;
+}
+
+export default function withPopper(WrappedComponent) {
+  return class extends React.Component<Props, State> {
+    constructor(props) {
+      super(props);
+      this.setState = this.setState.bind(this);
+      this.state = {
+        placement: this.props.placement || 'auto',
+        show: false,
+      };
+    }
+
+    componentWillReceiveProps(nextProps) {
+      if (nextProps.placement && nextProps.placement !== this.state.placement) {
+        this.setState(prevState => {
+          return {
+            ...prevState,
+            placement: nextProps.placement,
+          };
+        });
+      }
+    }
+
+    showPopper = () => {
+      this.setState(prevState => ({
+        ...prevState,
+        show: true,
+      }));
+    };
+
+    hidePopper = () => {
+      this.setState(prevState => ({
+        ...prevState,
+        show: false,
+      }));
+    };
+
+    renderContent(content) {
+      if (typeof content === 'function') {
+        // If it's a function we assume it's a React component
+        const ReactComponent = content;
+        return <ReactComponent />;
+      }
+      return content;
+    }
+
+    render() {
+      const { show, placement } = this.state;
+      const className = this.props.className || '';
+
+      return (
+        <WrappedComponent
+          {...this.props}
+          showPopper={this.showPopper}
+          hidePopper={this.hidePopper}
+          renderContent={this.renderContent}
+          show={show}
+          placement={placement}
+          className={className}
+        />
+      );
+    }
+  };
+}

+ 0 - 58
public/app/core/components/Tooltip/withTooltip.tsx

@@ -1,58 +0,0 @@
-import React from 'react';
-import { Manager, Popper, Arrow } from 'react-popper';
-
-interface IwithTooltipProps {
-  placement?: string;
-  content: string | ((props: any) => JSX.Element);
-  className?: string;
-}
-
-export default function withTooltip(WrappedComponent) {
-  return class extends React.Component<IwithTooltipProps, any> {
-    constructor(props) {
-      super(props);
-
-      this.setState = this.setState.bind(this);
-      this.state = {
-        placement: this.props.placement || 'auto',
-        show: false,
-      };
-    }
-
-    componentWillReceiveProps(nextProps) {
-      if (nextProps.placement && nextProps.placement !== this.state.placement) {
-        this.setState(prevState => {
-          return {
-            ...prevState,
-            placement: nextProps.placement,
-          };
-        });
-      }
-    }
-
-    renderContent(content) {
-      if (typeof content === 'function') {
-        // If it's a function we assume it's a React component
-        const ReactComponent = content;
-        return <ReactComponent />;
-      }
-      return content;
-    }
-
-    render() {
-      const { content, className } = this.props;
-
-      return (
-        <Manager className={`popper__manager ${className || ''}`}>
-          <WrappedComponent {...this.props} tooltipSetState={this.setState} />
-          {this.state.show ? (
-            <Popper placement={this.state.placement} className="popper">
-              {this.renderContent(content)}
-              <Arrow className="popper__arrow" />
-            </Popper>
-          ) : null}
-        </Manager>
-      );
-    }
-  };
-}

+ 1 - 1
public/app/core/components/code_editor/code_editor.ts

@@ -84,7 +84,7 @@ function link(scope, elem, attrs) {
   // disable depreacation warning
   // disable depreacation warning
   codeEditor.$blockScrolling = Infinity;
   codeEditor.$blockScrolling = Infinity;
   // Padding hacks
   // Padding hacks
-  (codeEditor.renderer as any).setScrollMargin(15, 15);
+  (codeEditor.renderer as any).setScrollMargin(10, 10);
   codeEditor.renderer.setPadding(10);
   codeEditor.renderer.setPadding(10);
 
 
   setThemeMode();
   setThemeMode();

+ 9 - 22
public/app/core/components/colorpicker/ColorPicker.tsx

@@ -1,6 +1,5 @@
 import React from 'react';
 import React from 'react';
 import ReactDOM from 'react-dom';
 import ReactDOM from 'react-dom';
-import $ from 'jquery';
 import Drop from 'tether-drop';
 import Drop from 'tether-drop';
 import { ColorPickerPopover } from './ColorPickerPopover';
 import { ColorPickerPopover } from './ColorPickerPopover';
 import { react2AngularDirective } from 'app/core/utils/react2angular';
 import { react2AngularDirective } from 'app/core/utils/react2angular';
@@ -11,29 +10,17 @@ export interface Props {
 }
 }
 
 
 export class ColorPicker extends React.Component<Props, any> {
 export class ColorPicker extends React.Component<Props, any> {
-  pickerElem: any;
+  pickerElem: HTMLElement;
   colorPickerDrop: any;
   colorPickerDrop: any;
 
 
-  constructor(props) {
-    super(props);
-    this.openColorPicker = this.openColorPicker.bind(this);
-    this.closeColorPicker = this.closeColorPicker.bind(this);
-    this.setPickerElem = this.setPickerElem.bind(this);
-    this.onColorSelect = this.onColorSelect.bind(this);
-  }
-
-  setPickerElem(elem) {
-    this.pickerElem = $(elem);
-  }
-
-  openColorPicker() {
+  openColorPicker = () => {
     const dropContent = <ColorPickerPopover color={this.props.color} onColorSelect={this.onColorSelect} />;
     const dropContent = <ColorPickerPopover color={this.props.color} onColorSelect={this.onColorSelect} />;
 
 
     const dropContentElem = document.createElement('div');
     const dropContentElem = document.createElement('div');
     ReactDOM.render(dropContent, dropContentElem);
     ReactDOM.render(dropContent, dropContentElem);
 
 
     const drop = new Drop({
     const drop = new Drop({
-      target: this.pickerElem[0],
+      target: this.pickerElem,
       content: dropContentElem,
       content: dropContentElem,
       position: 'top center',
       position: 'top center',
       classes: 'drop-popover',
       classes: 'drop-popover',
@@ -48,23 +35,23 @@ export class ColorPicker extends React.Component<Props, any> {
 
 
     this.colorPickerDrop = drop;
     this.colorPickerDrop = drop;
     this.colorPickerDrop.open();
     this.colorPickerDrop.open();
-  }
+  };
 
 
-  closeColorPicker() {
+  closeColorPicker = () => {
     setTimeout(() => {
     setTimeout(() => {
       if (this.colorPickerDrop && this.colorPickerDrop.tether) {
       if (this.colorPickerDrop && this.colorPickerDrop.tether) {
         this.colorPickerDrop.destroy();
         this.colorPickerDrop.destroy();
       }
       }
     }, 100);
     }, 100);
-  }
+  };
 
 
-  onColorSelect(color) {
+  onColorSelect = color => {
     this.props.onChange(color);
     this.props.onChange(color);
-  }
+  };
 
 
   render() {
   render() {
     return (
     return (
-      <div className="sp-replacer sp-light" onClick={this.openColorPicker} ref={this.setPickerElem}>
+      <div className="sp-replacer sp-light" onClick={this.openColorPicker} ref={element => (this.pickerElem = element)}>
         <div className="sp-preview">
         <div className="sp-preview">
           <div className="sp-preview-inner" style={{ backgroundColor: this.props.color }} />
           <div className="sp-preview-inner" style={{ backgroundColor: this.props.color }} />
         </div>
         </div>

+ 2 - 2
public/app/core/components/manage_dashboards/manage_dashboards.html

@@ -64,10 +64,10 @@
 
 
   <div class="search-results" ng-show="ctrl.sections.length > 0">
   <div class="search-results" ng-show="ctrl.sections.length > 0">
     <div class="search-results-filter-row">
     <div class="search-results-filter-row">
-      <gf-form-switch
+      <gf-form-checkbox
         on-change="ctrl.onSelectAllChanged()"
         on-change="ctrl.onSelectAllChanged()"
         checked="ctrl.selectAllChecked"
         checked="ctrl.selectAllChecked"
-        switch-class="gf-form-switch--transparent gf-form-switch--search-result-filter-row__checkbox"
+        switch-class="gf-form-checkbox--transparent"
       />
       />
       <div class="search-results-filter-row__filters">
       <div class="search-results-filter-row__filters">
         <div class="gf-form-select-wrapper" ng-show="!(ctrl.canMove || ctrl.canDelete)">
         <div class="gf-form-select-wrapper" ng-show="!(ctrl.canMove || ctrl.canDelete)">

+ 8 - 8
public/app/core/components/search/search_results.html

@@ -1,12 +1,12 @@
 <div ng-repeat="section in ctrl.results" class="search-section">
 <div ng-repeat="section in ctrl.results" class="search-section">
   <div class="search-section__header pointer" ng-hide="section.hideHeader" ng-class="{'selected': section.selected}" ng-click="ctrl.toggleFolderExpand(section)">
   <div class="search-section__header pointer" ng-hide="section.hideHeader" ng-class="{'selected': section.selected}" ng-click="ctrl.toggleFolderExpand(section)">
-    <div ng-click="ctrl.toggleSelection(section, $event)">
-      <gf-form-switch
+    <div ng-click="ctrl.toggleSelection(section, $event)" class="center-vh">
+      <gf-form-checkbox
          ng-show="ctrl.editable"
          ng-show="ctrl.editable"
          on-change="ctrl.selectionChanged($event)"
          on-change="ctrl.selectionChanged($event)"
          checked="section.checked"
          checked="section.checked"
-         switch-class="gf-form-switch--transparent gf-form-switch--search-result__section">
-      </gf-form-switch>
+         switch-class="gf-form-checkbox--transparent">
+      </gf-form-checkbox>
     </div>
     </div>
     <i class="search-section__header__icon" ng-class="section.icon"></i>
     <i class="search-section__header__icon" ng-class="section.icon"></i>
     <span class="search-section__header__text">{{::section.title}}</span>
     <span class="search-section__header__text">{{::section.title}}</span>
@@ -21,13 +21,13 @@
 
 
   <div ng-if="section.expanded">
   <div ng-if="section.expanded">
     <a ng-repeat="item in section.items" class="search-item search-item--indent" ng-class="{'selected': item.selected}" ng-href="{{::item.url}}" >
     <a ng-repeat="item in section.items" class="search-item search-item--indent" ng-class="{'selected': item.selected}" ng-href="{{::item.url}}" >
-      <div ng-click="ctrl.toggleSelection(item, $event)">
-        <gf-form-switch
+      <div ng-click="ctrl.toggleSelection(item, $event)" class="center-vh">
+        <gf-form-checkbox
            ng-show="ctrl.editable"
            ng-show="ctrl.editable"
            on-change="ctrl.selectionChanged()"
            on-change="ctrl.selectionChanged()"
            checked="item.checked"
            checked="item.checked"
-           switch-class="gf-form-switch--transparent gf-form-switch--search-result__item">
-        </gf-form-switch>
+           switch-class="gf-form-checkbox--transparent">
+        </gf-form-checkbox>
       </div>
       </div>
       <span class="search-item__icon">
       <span class="search-item__icon">
         <i class="gicon mini gicon-dashboard-list"></i>
         <i class="gicon mini gicon-dashboard-list"></i>

+ 45 - 9
public/app/core/components/switch.ts

@@ -1,16 +1,33 @@
 import coreModule from 'app/core/core_module';
 import coreModule from 'app/core/core_module';
 
 
 const template = `
 const template = `
-<label for="check-{{ctrl.id}}" class="gf-form-label {{ctrl.labelClass}} pointer" ng-show="ctrl.label">
-  {{ctrl.label}}
-  <info-popover mode="right-normal" ng-if="ctrl.tooltip" position="top center">
-    {{ctrl.tooltip}}
-  </info-popover>
+<label for="check-{{ctrl.id}}" class="gf-form-switch-container">
+  <div class="gf-form-label {{ctrl.labelClass}}" ng-show="ctrl.label">
+    {{ctrl.label}}
+    <info-popover mode="right-normal" ng-if="ctrl.tooltip" position="top center">
+      {{ctrl.tooltip}}
+    </info-popover>
+  </div>
+  <div class="gf-form-switch {{ctrl.switchClass}}" ng-if="ctrl.show">
+    <input id="check-{{ctrl.id}}" type="checkbox" ng-model="ctrl.checked" ng-change="ctrl.internalOnChange()">
+    <span class="gf-form-switch__slider"></span>
+  </div>
+</label>
+`;
+
+const checkboxTemplate = `
+<label for="check-{{ctrl.id}}" class="gf-form-switch-container">
+   <div class="gf-form-label {{ctrl.labelClass}}" ng-show="ctrl.label">
+    {{ctrl.label}}
+    <info-popover mode="right-normal" ng-if="ctrl.tooltip" position="top center">
+      {{ctrl.tooltip}}
+    </info-popover>
+  </div>
+  <div class="gf-form-checkbox {{ctrl.switchClass}}" ng-if="ctrl.show">
+    <input id="check-{{ctrl.id}}" type="checkbox" ng-model="ctrl.checked" ng-change="ctrl.internalOnChange()">
+    <span class="gf-form-switch__checkbox"></span>
+  </div>
 </label>
 </label>
-<div class="gf-form-switch {{ctrl.switchClass}}" ng-if="ctrl.show">
-  <input id="check-{{ctrl.id}}" type="checkbox" ng-model="ctrl.checked" ng-change="ctrl.internalOnChange()">
-  <label for="check-{{ctrl.id}}" data-on="Yes" data-off="No"></label>
-</div>
 `;
 `;
 
 
 export class SwitchCtrl {
 export class SwitchCtrl {
@@ -51,4 +68,23 @@ export function switchDirective() {
   };
   };
 }
 }
 
 
+export function checkboxDirective() {
+  return {
+    restrict: 'E',
+    controller: SwitchCtrl,
+    controllerAs: 'ctrl',
+    bindToController: true,
+    scope: {
+      checked: '=',
+      label: '@',
+      labelClass: '@',
+      tooltip: '@',
+      switchClass: '@',
+      onChange: '&',
+    },
+    template: checkboxTemplate,
+  };
+}
+
 coreModule.directive('gfFormSwitch', switchDirective);
 coreModule.directive('gfFormSwitch', switchDirective);
+coreModule.directive('gfFormCheckbox', checkboxDirective);

+ 4 - 0
public/app/core/constants.ts

@@ -11,3 +11,7 @@ export const LS_PANEL_COPY_KEY = 'panel-copy';
 
 
 export const DASHBOARD_TOOLBAR_HEIGHT = 55;
 export const DASHBOARD_TOOLBAR_HEIGHT = 55;
 export const DASHBOARD_TOP_PADDING = 20;
 export const DASHBOARD_TOP_PADDING = 20;
+
+export const PANEL_HEADER_HEIGHT = 27;
+export const PANEL_BORDER = 2;
+export const PANEL_OPTIONS_KEY_PREFIX = 'options-';

+ 6 - 3
public/app/core/directives/dash_class.ts

@@ -1,3 +1,4 @@
+import $ from 'jquery';
 import _ from 'lodash';
 import _ from 'lodash';
 import coreModule from '../core_module';
 import coreModule from '../core_module';
 
 
@@ -5,18 +6,20 @@ import coreModule from '../core_module';
 function dashClass($timeout) {
 function dashClass($timeout) {
   return {
   return {
     link: ($scope, elem) => {
     link: ($scope, elem) => {
+      const body = $('body');
+
       $scope.ctrl.dashboard.events.on('view-mode-changed', panel => {
       $scope.ctrl.dashboard.events.on('view-mode-changed', panel => {
         console.log('view-mode-changed', panel.fullscreen);
         console.log('view-mode-changed', panel.fullscreen);
         if (panel.fullscreen) {
         if (panel.fullscreen) {
-          elem.addClass('panel-in-fullscreen');
+          body.addClass('panel-in-fullscreen');
         } else {
         } else {
           $timeout(() => {
           $timeout(() => {
-            elem.removeClass('panel-in-fullscreen');
+            body.removeClass('panel-in-fullscreen');
           });
           });
         }
         }
       });
       });
 
 
-      elem.toggleClass('panel-in-fullscreen', $scope.ctrl.dashboard.meta.fullscreen === true);
+      body.toggleClass('panel-in-fullscreen', $scope.ctrl.dashboard.meta.fullscreen === true);
 
 
       $scope.$watch('ctrl.dashboardViewState.state.editview', newValue => {
       $scope.$watch('ctrl.dashboardViewState.state.editview', newValue => {
         if (newValue) {
         if (newValue) {

+ 2 - 2
public/app/core/directives/dropdown_typeahead.ts

@@ -12,7 +12,7 @@ export function dropdownTypeahead($compile) {
   const buttonTemplate =
   const buttonTemplate =
     '<a class="gf-form-label tight-form-func dropdown-toggle"' +
     '<a class="gf-form-label tight-form-func dropdown-toggle"' +
     ' tabindex="1" gf-dropdown="menuItems" data-toggle="dropdown"' +
     ' tabindex="1" gf-dropdown="menuItems" data-toggle="dropdown"' +
-    ' data-placement="top"><i class="fa fa-plus"></i></a>';
+    ' ><i class="fa fa-plus"></i></a>';
 
 
   return {
   return {
     scope: {
     scope: {
@@ -130,7 +130,7 @@ export function dropdownTypeahead2($compile) {
   const buttonTemplate =
   const buttonTemplate =
     '<a class="gf-form-input dropdown-toggle"' +
     '<a class="gf-form-input dropdown-toggle"' +
     ' tabindex="1" gf-dropdown="menuItems" data-toggle="dropdown"' +
     ' tabindex="1" gf-dropdown="menuItems" data-toggle="dropdown"' +
-    ' data-placement="top"><i class="fa fa-plus"></i></a>';
+    ' ><i class="fa fa-plus"></i></a>';
 
 
   return {
   return {
     scope: {
     scope: {

+ 1 - 0
public/app/core/reducers/location.ts

@@ -18,6 +18,7 @@ export const locationReducer = (state = initialState, action: Action): LocationS
 
 
       if (action.payload.partial) {
       if (action.payload.partial) {
         query = _.defaults(query, state.query);
         query = _.defaults(query, state.query);
+        query = _.omitBy(query, _.isNull);
       }
       }
 
 
       return {
       return {

+ 8 - 0
public/app/core/services/AngularLoader.ts

@@ -4,6 +4,8 @@ import _ from 'lodash';
 
 
 export interface AngularComponent {
 export interface AngularComponent {
   destroy();
   destroy();
+  digest();
+  getScope();
 }
 }
 
 
 export class AngularLoader {
 export class AngularLoader {
@@ -24,6 +26,12 @@ export class AngularLoader {
         scope.$destroy();
         scope.$destroy();
         compiledElem.remove();
         compiledElem.remove();
       },
       },
+      digest: () => {
+        scope.$digest();
+      },
+      getScope: () => {
+        return scope;
+      },
     };
     };
   }
   }
 }
 }

+ 1 - 1
public/app/core/services/bridge_srv.ts

@@ -1,6 +1,6 @@
 import coreModule from 'app/core/core_module';
 import coreModule from 'app/core/core_module';
 import appEvents from 'app/core/app_events';
 import appEvents from 'app/core/app_events';
-import { store } from 'app/store/configureStore';
+import { store } from 'app/store/store';
 import locationUtil from 'app/core/utils/location_util';
 import locationUtil from 'app/core/utils/location_util';
 import { updateLocation } from 'app/core/actions';
 import { updateLocation } from 'app/core/actions';
 
 

+ 2 - 3
public/app/core/table_model.ts

@@ -86,11 +86,10 @@ export function mergeTablesIntoModel(dst?: TableModel, ...tables: TableModel[]):
   if (arguments.length === 1) {
   if (arguments.length === 1) {
     return model;
     return model;
   }
   }
-
   // Single query returns data columns and rows as is
   // Single query returns data columns and rows as is
   if (arguments.length === 2) {
   if (arguments.length === 2) {
-    model.columns = [...tables[0].columns];
-    model.rows = [...tables[0].rows];
+    model.columns = tables[0].hasOwnProperty('columns') ? [...tables[0].columns] : [];
+    model.rows = tables[0].hasOwnProperty('rows') ? [...tables[0].rows] : [];
     return model;
     return model;
   }
   }
 
 

+ 1 - 1
public/app/core/utils/connectWithReduxStore.tsx

@@ -1,6 +1,6 @@
 import React from 'react';
 import React from 'react';
 import { connect } from 'react-redux';
 import { connect } from 'react-redux';
-import { store } from '../../store/configureStore';
+import { store } from '../../store/store';
 
 
 export function connectWithStore(WrappedComponent, ...args) {
 export function connectWithStore(WrappedComponent, ...args) {
   const ConnectedWrappedComponent = connect(...args)(WrappedComponent);
   const ConnectedWrappedComponent = connect(...args)(WrappedComponent);

+ 2 - 1
public/app/core/utils/kbn.ts

@@ -405,7 +405,8 @@ kbn.valueFormats.percentunit = (size, decimals) => {
 };
 };
 
 
 /* Formats the value to hex. Uses float if specified decimals are not 0.
 /* Formats the value to hex. Uses float if specified decimals are not 0.
- * There are two options, one with 0x, and one without */
+ * There are two submenu
+ * , one with 0x, and one without */
 
 
 kbn.valueFormats.hex = (value, decimals) => {
 kbn.valueFormats.hex = (value, decimals) => {
   if (value == null) {
   if (value == null) {

+ 9 - 0
public/app/core/utils/rangeutil.ts

@@ -159,3 +159,12 @@ export function describeTimeRange(range: RawTimeRange): string {
 
 
   return range.from.toString() + ' to ' + range.to.toString();
   return range.from.toString() + ' to ' + range.to.toString();
 }
 }
+
+export const isValidTimeSpan = (value: string) => {
+  if (value.indexOf('$') === 0 || value.indexOf('+$') === 0) {
+    return true;
+  }
+
+  const info = describeTextRange(value);
+  return info.invalid !== true;
+};

+ 16 - 0
public/app/core/utils/validate.ts

@@ -0,0 +1,16 @@
+import { ValidationRule, ValidationEvents } from 'app/types';
+import { EventsWithValidation } from 'app/core/components/Form/Input';
+
+export const validate = (value: string, validationRules: ValidationRule[]) => {
+  const errors = validationRules.reduce((acc, currRule) => {
+    if (!currRule.rule(value)) {
+      return acc.concat(currRule.errorMessage);
+    }
+    return acc;
+  }, []);
+  return errors.length > 0 ? errors : null;
+};
+
+export const hasValidationEvent = (event: EventsWithValidation, validationEvents: ValidationEvents) => {
+  return validationEvents && validationEvents[event];
+};

+ 4 - 1
public/app/features/alerting/AlertTabCtrl.ts

@@ -1,4 +1,5 @@
 import _ from 'lodash';
 import _ from 'lodash';
+import coreModule from 'app/core/core_module';
 import { ThresholdMapper } from './state/ThresholdMapper';
 import { ThresholdMapper } from './state/ThresholdMapper';
 import { QueryPart } from 'app/core/components/query_part/query_part';
 import { QueryPart } from 'app/core/components/query_part/query_part';
 import alertDef from './state/alertDef';
 import alertDef from './state/alertDef';
@@ -261,7 +262,7 @@ export class AlertTabCtrl {
       this.datasourceSrv.get(datasourceName).then(ds => {
       this.datasourceSrv.get(datasourceName).then(ds => {
         if (!ds.meta.alerting) {
         if (!ds.meta.alerting) {
           this.error = 'The datasource does not support alerting queries';
           this.error = 'The datasource does not support alerting queries';
-        } else if (ds.targetContainsTemplate(foundTarget)) {
+        } else if (ds.targetContainsTemplate && ds.targetContainsTemplate(foundTarget)) {
           this.error = 'Template variables are not supported in alert queries';
           this.error = 'Template variables are not supported in alert queries';
         } else {
         } else {
           this.error = '';
           this.error = '';
@@ -430,3 +431,5 @@ export function alertTab() {
     controller: AlertTabCtrl,
     controller: AlertTabCtrl,
   };
   };
 }
 }
+
+coreModule.directive('alertTab', alertTab);

+ 175 - 171
public/app/features/alerting/partials/alert_tab.html

@@ -1,187 +1,191 @@
-<div class="edit-tab-with-sidemenu" ng-if="ctrl.alert">
-  <aside class="edit-sidemenu-aside">
-    <ul class="edit-sidemenu">
-      <li ng-class="{active: ctrl.subTabIndex === 0}">
-        <a ng-click="ctrl.changeTabIndex(0)">Alert Config</a>
-      </li>
-      <li ng-class="{active: ctrl.subTabIndex === 1}">
-        <a ng-click="ctrl.changeTabIndex(1)">
-          Notifications <span class="muted">({{ctrl.alertNotifications.length}})</span>
-        </a>
-      </li>
-      <li ng-class="{active: ctrl.subTabIndex === 2}">
-        <a ng-click="ctrl.changeTabIndex(2)">State history</a>
-      </li>
-      <li>
-        <a ng-click="ctrl.delete()">Delete</a>
-      </li>
-    </ul>
-  </aside>
+<div class="panel-option-section__body" ng-if="ctrl.alert">
+	<div class="edit-tab-with-sidemenu">
+		<aside class="edit-sidemenu-aside">
+			<ul class="edit-sidemenu">
+				<li ng-class="{active: ctrl.subTabIndex === 0}">
+					<a ng-click="ctrl.changeTabIndex(0)">Alert Config</a>
+				</li>
+				<li ng-class="{active: ctrl.subTabIndex === 1}">
+					<a ng-click="ctrl.changeTabIndex(1)">
+						Notifications <span class="muted">({{ctrl.alertNotifications.length}})</span>
+					</a>
+				</li>
+				<li ng-class="{active: ctrl.subTabIndex === 2}">
+					<a ng-click="ctrl.changeTabIndex(2)">State history</a>
+				</li>
+				<li>
+					<a ng-click="ctrl.delete()">Delete</a>
+				</li>
+			</ul>
+		</aside>
 
 
-  <div class="edit-tab-content">
-    <div ng-if="ctrl.subTabIndex === 0">
-      <div class="alert alert-error m-b-2" ng-show="ctrl.error">
-        <i class="fa fa-warning"></i> {{ctrl.error}}
-      </div>
+		<div class="edit-tab-content">
+			<div ng-if="ctrl.subTabIndex === 0">
+				<div class="alert alert-error m-b-2" ng-show="ctrl.error">
+					<i class="fa fa-warning"></i> {{ctrl.error}}
+				</div>
 
 
-      <div class="gf-form-group">
-        <h5 class="section-heading">Alert Config</h5>
-        <div class="gf-form">
-          <span class="gf-form-label width-6">Name</span>
-          <input type="text" class="gf-form-input width-20" ng-model="ctrl.alert.name">
-        </div>
-        <div class="gf-form-inline">
-          <div class="gf-form">
-            <span class="gf-form-label width-9">Evaluate every</span>
-            <input class="gf-form-input max-width-6" type="text" ng-model="ctrl.alert.frequency">
-          </div>
-          <div class="gf-form max-width-11">
-            <label class="gf-form-label width-5">For</label>
-            <input type="text" class="gf-form-input max-width-6" ng-model="ctrl.alert.for" spellcheck='false' placeholder="5m">
-            <info-popover mode="right-absolute">
-                If an alert rule has a configured For and the query violates the configured threshold it will first go from OK to Pending. 
-                Going from OK to Pending Grafana will not send any notifications. Once the alert rule has been firing for more than For duration, it will change to Alerting and send alert notifications. 
-            </info-popover>
-          </div>
-        </div>
-      </div>
+				<div class="gf-form-group">
+					<h5 class="section-heading">Alert Config</h5>
+					<div class="gf-form">
+						<span class="gf-form-label width-6">Name</span>
+						<input type="text" class="gf-form-input width-20" ng-model="ctrl.alert.name">
+					</div>
+					<div class="gf-form-inline">
+						<div class="gf-form">
+							<span class="gf-form-label width-9">Evaluate every</span>
+							<input class="gf-form-input max-width-6" type="text" ng-model="ctrl.alert.frequency">
+						</div>
+						<div class="gf-form max-width-11">
+							<label class="gf-form-label width-5">For</label>
+							<input type="text" class="gf-form-input max-width-6" ng-model="ctrl.alert.for" spellcheck='false' placeholder="5m">
+							<info-popover mode="right-absolute">
+									If an alert rule has a configured For and the query violates the configured threshold it will first go from OK to Pending.
+									Going from OK to Pending Grafana will not send any notifications. Once the alert rule has been firing for more than For duration, it will change to Alerting and send alert notifications.
+							</info-popover>
+						</div>
+					</div>
+				</div>
 
 
-      <div class="gf-form-group">
-        <h5 class="section-heading">Conditions</h5>
-        <div class="gf-form-inline" ng-repeat="conditionModel in ctrl.conditionModels">
-          <div class="gf-form">
-            <metric-segment-model css-class="query-keyword width-5" ng-if="$index" property="conditionModel.operator.type" options="ctrl.evalOperators" custom="false"></metric-segment-model>
-            <span class="gf-form-label query-keyword width-5" ng-if="$index===0">WHEN</span>
-          </div>
-                <div class="gf-form">
-            <query-part-editor class="gf-form-label query-part width-9" part="conditionModel.reducerPart" handle-event="ctrl.handleReducerPartEvent(conditionModel, $event)">
-            </query-part-editor>
-                  <span class="gf-form-label query-keyword">OF</span>
-          </div>
-          <div class="gf-form">
-            <query-part-editor class="gf-form-label query-part" part="conditionModel.queryPart" handle-event="ctrl.handleQueryPartEvent(conditionModel, $event)">
-            </query-part-editor>
-          </div>
-          <div class="gf-form">
-            <metric-segment-model property="conditionModel.evaluator.type" options="ctrl.evalFunctions" custom="false" css-class="query-keyword" on-change="ctrl.evaluatorTypeChanged(conditionModel.evaluator)"></metric-segment-model>
-            <input class="gf-form-input max-width-9" type="number" step="any" ng-hide="conditionModel.evaluator.params.length === 0" ng-model="conditionModel.evaluator.params[0]" ng-change="ctrl.evaluatorParamsChanged()">
-            <label class="gf-form-label query-keyword" ng-show="conditionModel.evaluator.params.length === 2">TO</label>
-            <input class="gf-form-input max-width-9" type="number" step="any" ng-if="conditionModel.evaluator.params.length === 2" ng-model="conditionModel.evaluator.params[1]" ng-change="ctrl.evaluatorParamsChanged()">
-          </div>
-          <div class="gf-form">
-            <label class="gf-form-label">
-              <a class="pointer" tabindex="1" ng-click="ctrl.removeCondition($index)">
-                <i class="fa fa-trash"></i>
-              </a>
-            </label>
-          </div>
-        </div>
+				<div class="gf-form-group">
+					<h5 class="section-heading">Conditions</h5>
+					<div class="gf-form-inline" ng-repeat="conditionModel in ctrl.conditionModels">
+						<div class="gf-form">
+							<metric-segment-model css-class="query-keyword width-5" ng-if="$index" property="conditionModel.operator.type" options="ctrl.evalOperators" custom="false"></metric-segment-model>
+							<span class="gf-form-label query-keyword width-5" ng-if="$index===0">WHEN</span>
+						</div>
+									<div class="gf-form">
+							<query-part-editor class="gf-form-label query-part width-9" part="conditionModel.reducerPart" handle-event="ctrl.handleReducerPartEvent(conditionModel, $event)">
+							</query-part-editor>
+										<span class="gf-form-label query-keyword">OF</span>
+						</div>
+						<div class="gf-form">
+							<query-part-editor class="gf-form-label query-part" part="conditionModel.queryPart" handle-event="ctrl.handleQueryPartEvent(conditionModel, $event)">
+							</query-part-editor>
+						</div>
+						<div class="gf-form">
+							<metric-segment-model property="conditionModel.evaluator.type" options="ctrl.evalFunctions" custom="false" css-class="query-keyword" on-change="ctrl.evaluatorTypeChanged(conditionModel.evaluator)"></metric-segment-model>
+							<input class="gf-form-input max-width-9" type="number" step="any" ng-hide="conditionModel.evaluator.params.length === 0" ng-model="conditionModel.evaluator.params[0]" ng-change="ctrl.evaluatorParamsChanged()">
+							<label class="gf-form-label query-keyword" ng-show="conditionModel.evaluator.params.length === 2">TO</label>
+							<input class="gf-form-input max-width-9" type="number" step="any" ng-if="conditionModel.evaluator.params.length === 2" ng-model="conditionModel.evaluator.params[1]" ng-change="ctrl.evaluatorParamsChanged()">
+						</div>
+						<div class="gf-form">
+							<label class="gf-form-label">
+								<a class="pointer" tabindex="1" ng-click="ctrl.removeCondition($index)">
+									<i class="fa fa-trash"></i>
+								</a>
+							</label>
+						</div>
+					</div>
 
 
-        <div class="gf-form">
-          <label class="gf-form-label dropdown">
-            <a class="pointer dropdown-toggle" data-toggle="dropdown">
-              <i class="fa fa-plus"></i>
-            </a>
-            <ul class="dropdown-menu" role="menu">
-              <li ng-repeat="ct in ctrl.conditionTypes" role="menuitem">
-                <a ng-click="ctrl.addCondition(ct.value);">{{ct.text}}</a>
-              </li>
-            </ul>
-          </label>
-        </div>
-      </div>
+					<div class="gf-form">
+						<label class="gf-form-label dropdown">
+							<a class="pointer dropdown-toggle" data-toggle="dropdown">
+								<i class="fa fa-plus"></i>
+							</a>
+							<ul class="dropdown-menu" role="menu">
+								<li ng-repeat="ct in ctrl.conditionTypes" role="menuitem">
+									<a ng-click="ctrl.addCondition(ct.value);">{{ct.text}}</a>
+								</li>
+							</ul>
+						</label>
+					</div>
+				</div>
 
 
-      <div class="gf-form-group">
-        <div class="gf-form">
-                <span class="gf-form-label width-18">If no data or all values are null</span>
-                <span class="gf-form-label query-keyword">SET STATE TO</span>
-          <div class="gf-form-select-wrapper">
-            <select class="gf-form-input" ng-model="ctrl.alert.noDataState" ng-options="f.value as f.text for f in ctrl.noDataModes">
-            </select>
-          </div>
-        </div>
+				<div class="gf-form-group">
+					<div class="gf-form">
+									<span class="gf-form-label width-18">If no data or all values are null</span>
+									<span class="gf-form-label query-keyword">SET STATE TO</span>
+						<div class="gf-form-select-wrapper">
+							<select class="gf-form-input" ng-model="ctrl.alert.noDataState" ng-options="f.value as f.text for f in ctrl.noDataModes">
+							</select>
+						</div>
+					</div>
 
 
-        <div class="gf-form">
-                <span class="gf-form-label width-18">If execution error or timeout</span>
-                <span class="gf-form-label query-keyword">SET STATE TO</span>
-          <div class="gf-form-select-wrapper">
-            <select class="gf-form-input" ng-model="ctrl.alert.executionErrorState" ng-options="f.value as f.text for f in ctrl.executionErrorModes">
-            </select>
-          </div>
-        </div>
+					<div class="gf-form">
+									<span class="gf-form-label width-18">If execution error or timeout</span>
+									<span class="gf-form-label query-keyword">SET STATE TO</span>
+						<div class="gf-form-select-wrapper">
+							<select class="gf-form-input" ng-model="ctrl.alert.executionErrorState" ng-options="f.value as f.text for f in ctrl.executionErrorModes">
+							</select>
+						</div>
+					</div>
 
 
-        <div class="gf-form-button-row">
-          <button class="btn btn-inverse" ng-click="ctrl.test()">
-            Test Rule
-          </button>
-        </div>
-      </div>
+					<div class="gf-form-button-row">
+						<button class="btn btn-inverse" ng-click="ctrl.test()">
+							Test Rule
+						</button>
+					</div>
+				</div>
 
 
-      <div class="gf-form-group" ng-if="ctrl.testing">
-        Evaluating rule <i class="fa fa-spinner fa-spin"></i>
-      </div>
+				<div class="gf-form-group" ng-if="ctrl.testing">
+					Evaluating rule <i class="fa fa-spinner fa-spin"></i>
+				</div>
 
 
-      <div class="gf-form-group" ng-if="ctrl.testResult">
-        <json-tree root-name="result" object="ctrl.testResult" start-expanded="true"></json-tree>
-      </div>
-    </div>
+				<div class="gf-form-group" ng-if="ctrl.testResult">
+					<json-tree root-name="result" object="ctrl.testResult" start-expanded="true"></json-tree>
+				</div>
+			</div>
 
 
-    <div class="gf-form-group" ng-if="ctrl.subTabIndex === 1">
-      <h5 class="section-heading">Notifications</h5>
-      <div class="gf-form-inline">
-        <div class="gf-form max-width-30">
-          <span class="gf-form-label width-8">Send to</span>
-          <span class="gf-form-label" ng-repeat="nc in ctrl.alertNotifications" ng-style="{'background-color': nc.bgColor }">
-            <i class="{{nc.iconClass}}"></i>&nbsp;{{nc.name}}&nbsp;
-            <i class="fa fa-remove pointer muted" ng-click="ctrl.removeNotification($index)" ng-if="nc.isDefault === false"></i>
-          </span>
-          <metric-segment segment="ctrl.addNotificationSegment" get-options="ctrl.getNotifications()" on-change="ctrl.notificationAdded()"></metric-segment>
-        </div>
-      </div>
-      <div class="gf-form gf-form--v-stretch">
-        <span class="gf-form-label width-8">Message</span>
-        <textarea class="gf-form-input" rows="10" ng-model="ctrl.alert.message"  placeholder="Notification message details..."></textarea>
-      </div>
-    </div>
+			<div class="gf-form-group" ng-if="ctrl.subTabIndex === 1">
+				<h5 class="section-heading">Notifications</h5>
+				<div class="gf-form-inline">
+					<div class="gf-form max-width-30">
+						<span class="gf-form-label width-8">Send to</span>
+						<span class="gf-form-label" ng-repeat="nc in ctrl.alertNotifications" ng-style="{'background-color': nc.bgColor }">
+							<i class="{{nc.iconClass}}"></i>&nbsp;{{nc.name}}&nbsp;
+							<i class="fa fa-remove pointer muted" ng-click="ctrl.removeNotification($index)" ng-if="nc.isDefault === false"></i>
+						</span>
+						<metric-segment segment="ctrl.addNotificationSegment" get-options="ctrl.getNotifications()" on-change="ctrl.notificationAdded()"></metric-segment>
+					</div>
+				</div>
+				<div class="gf-form gf-form--v-stretch">
+					<span class="gf-form-label width-8">Message</span>
+					<textarea class="gf-form-input" rows="10" ng-model="ctrl.alert.message"  placeholder="Notification message details..."></textarea>
+				</div>
+			</div>
 
 
-    <div class="gf-form-group" style="max-width: 720px;" ng-if="ctrl.subTabIndex === 2">
-      <button class="btn btn-mini btn-danger pull-right" ng-click="ctrl.clearHistory()"><i class="fa fa-trash"></i>&nbsp;Clear history</button>
-          <h5 class="section-heading" style="whitespace: nowrap">
-        State history <span class="muted small">(last 50 state changes)</span>
-      </h5>
+			<div class="gf-form-group" style="max-width: 720px;" ng-if="ctrl.subTabIndex === 2">
+				<button class="btn btn-mini btn-danger pull-right" ng-click="ctrl.clearHistory()"><i class="fa fa-trash"></i>&nbsp;Clear history</button>
+						<h5 class="section-heading" style="whitespace: nowrap">
+					State history <span class="muted small">(last 50 state changes)</span>
+				</h5>
 
 
-      <div ng-show="ctrl.alertHistory.length === 0">
-        <br>
-        <i>No state changes recorded</i>
-      </div>
+				<div ng-show="ctrl.alertHistory.length === 0">
+					<br>
+					<i>No state changes recorded</i>
+				</div>
 
 
-      <ol class="alert-rule-list" >
-        <li class="alert-rule-item" ng-repeat="al in ctrl.alertHistory">
-          <div class="alert-rule-item__icon {{al.stateModel.stateClass}}">
-            <i class="{{al.stateModel.iconClass}}"></i>
-          </div>
-          <div class="alert-rule-item__body">
-            <div class="alert-rule-item__header">
-              <div class="alert-rule-item__text">
-                <span class="{{al.stateModel.stateClass}}">{{al.stateModel.text}}</span>
-              </div>
-            </div>
-            <span class="alert-list-info">{{al.info}}</span>
-          </div>
-          <div class="alert-rule-item__time">
-            <span>{{al.time}}</span>
-          </div>
-        </li>
-      </ol>
-    </div>
-  </div>
+				<ol class="alert-rule-list" >
+					<li class="alert-rule-item" ng-repeat="al in ctrl.alertHistory">
+						<div class="alert-rule-item__icon {{al.stateModel.stateClass}}">
+							<i class="{{al.stateModel.iconClass}}"></i>
+						</div>
+						<div class="alert-rule-item__body">
+							<div class="alert-rule-item__header">
+								<div class="alert-rule-item__text">
+									<span class="{{al.stateModel.stateClass}}">{{al.stateModel.text}}</span>
+								</div>
+							</div>
+							<span class="alert-list-info">{{al.info}}</span>
+						</div>
+						<div class="alert-rule-item__time">
+							<span>{{al.time}}</span>
+						</div>
+					</li>
+				</ol>
+			</div>
+		</div>
+	</div>
 </div>
 </div>
 
 
-<div class="gf-form-group" ng-if="!ctrl.alert">
-  <div class="gf-form-button-row">
-    <button class="btn btn-inverse" ng-click="ctrl.enable()">
-      <i class="icon-gf icon-gf-alert"></i>
-      Create Alert
-    </button>
-  </div>
+<div class="gf-form-group p-t-4 p-b-4" ng-if="!ctrl.alert">
+	<div class="empty-list-cta">
+		<div class="empty-list-cta__title">Panel has no alert rule defined</div>
+			<button class="empty-list-cta__button btn btn-xlarge btn-success" ng-click="ctrl.enable()">
+				<i class="icon-gf icon-gf-alert"></i>
+				Create Alert
+			</button>
+		</div>
+	</div>
 </div>
 </div>

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

@@ -143,7 +143,7 @@ export class DashboardMigrator {
       panelUpgrades.push(panel => {
       panelUpgrades.push(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';
   }
   }

+ 71 - 168
public/app/features/dashboard/dashgrid/AddPanelPanel.tsx

@@ -1,13 +1,12 @@
 import React from 'react';
 import React from 'react';
 import _ from 'lodash';
 import _ from 'lodash';
-import classNames from 'classnames';
 import config from 'app/core/config';
 import config from 'app/core/config';
 import { PanelModel } from '../panel_model';
 import { PanelModel } from '../panel_model';
 import { DashboardModel } from '../dashboard_model';
 import { DashboardModel } from '../dashboard_model';
-import ScrollBar from 'app/core/components/ScrollBar/ScrollBar';
 import store from 'app/core/store';
 import store from 'app/core/store';
 import { LS_PANEL_COPY_KEY } from 'app/core/constants';
 import { LS_PANEL_COPY_KEY } from 'app/core/constants';
-import Highlighter from 'react-highlight-words';
+import { updateLocation } from 'app/core/actions';
+import { store as reduxStore } from 'app/store/store';
 
 
 export interface AddPanelPanelProps {
 export interface AddPanelPanelProps {
   panel: PanelModel;
   panel: PanelModel;
@@ -15,64 +14,25 @@ export interface AddPanelPanelProps {
 }
 }
 
 
 export interface AddPanelPanelState {
 export interface AddPanelPanelState {
-  filter: string;
-  panelPlugins: any[];
   copiedPanelPlugins: any[];
   copiedPanelPlugins: any[];
-  tab: string;
 }
 }
 
 
 export class AddPanelPanel extends React.Component<AddPanelPanelProps, AddPanelPanelState> {
 export class AddPanelPanel extends React.Component<AddPanelPanelProps, AddPanelPanelState> {
-  private scrollbar: ScrollBar;
-
   constructor(props) {
   constructor(props) {
     super(props);
     super(props);
     this.handleCloseAddPanel = this.handleCloseAddPanel.bind(this);
     this.handleCloseAddPanel = this.handleCloseAddPanel.bind(this);
-    this.renderPanelItem = this.renderPanelItem.bind(this);
-    this.panelSizeChanged = this.panelSizeChanged.bind(this);
 
 
     this.state = {
     this.state = {
-      panelPlugins: this.getPanelPlugins(''),
-      copiedPanelPlugins: this.getCopiedPanelPlugins(''),
-      filter: '',
-      tab: 'Add',
+      copiedPanelPlugins: this.getCopiedPanelPlugins(),
     };
     };
   }
   }
 
 
-  componentDidMount() {
-    this.props.panel.events.on('panel-size-changed', this.panelSizeChanged);
-  }
-
-  componentWillUnmount() {
-    this.props.panel.events.off('panel-size-changed', this.panelSizeChanged);
-  }
-
-  panelSizeChanged() {
-    setTimeout(() => {
-      this.scrollbar.update();
-    });
-  }
-
-  getPanelPlugins(filter) {
-    let panels = _.chain(config.panels)
-      .filter({ hideFromList: false })
-      .map(item => item)
-      .value();
-
-    // add special row type
-    panels.push({ id: 'row', name: 'Row', sort: 8, info: { logos: { small: 'public/img/icn-row.svg' } } });
-
-    panels = this.filterPanels(panels, filter);
-
-    // add sort by sort property
-    return _.sortBy(panels, 'sort');
-  }
-
-  getCopiedPanelPlugins(filter) {
+  getCopiedPanelPlugins() {
     const panels = _.chain(config.panels)
     const panels = _.chain(config.panels)
       .filter({ hideFromList: false })
       .filter({ hideFromList: false })
       .map(item => item)
       .map(item => item)
       .value();
       .value();
-    let copiedPanels = [];
+    const copiedPanels = [];
 
 
     const copiedPanelJson = store.get(LS_PANEL_COPY_KEY);
     const copiedPanelJson = store.get(LS_PANEL_COPY_KEY);
     if (copiedPanelJson) {
     if (copiedPanelJson) {
@@ -86,32 +46,64 @@ export class AddPanelPanel extends React.Component<AddPanelPanelProps, AddPanelP
         copiedPanels.push(pluginCopy);
         copiedPanels.push(pluginCopy);
       }
       }
     }
     }
+    return _.sortBy(copiedPanels, 'sort');
+  }
 
 
-    copiedPanels = this.filterPanels(copiedPanels, filter);
+  handleCloseAddPanel(evt) {
+    evt.preventDefault();
+    this.props.dashboard.removePanel(this.props.dashboard.panels[0]);
+  }
 
 
-    return _.sortBy(copiedPanels, 'sort');
+  copyButton(panel) {
+    return (
+      <button className="btn-inverse btn" onClick={() => this.onPasteCopiedPanel(panel)} title={panel.name}>
+        Paste copied Panel
+      </button>
+    );
   }
   }
 
 
-  onAddPanel = panelPluginInfo => {
+  moveToEdit(panel) {
+    reduxStore.dispatch(
+      updateLocation({
+        query: {
+          panelId: panel.id,
+          edit: true,
+          fullscreen: true,
+        },
+        partial: true,
+      })
+    );
+  }
+
+  onCreateNewPanel = () => {
     const dashboard = this.props.dashboard;
     const dashboard = this.props.dashboard;
     const { gridPos } = this.props.panel;
     const { gridPos } = this.props.panel;
 
 
     const newPanel: any = {
     const newPanel: any = {
-      type: panelPluginInfo.id,
+      type: 'graph',
       title: 'Panel Title',
       title: 'Panel Title',
       gridPos: { x: gridPos.x, y: gridPos.y, w: gridPos.w, h: gridPos.h },
       gridPos: { x: gridPos.x, y: gridPos.y, w: gridPos.w, h: gridPos.h },
     };
     };
 
 
-    if (panelPluginInfo.id === 'row') {
-      newPanel.title = 'Row title';
-      newPanel.gridPos = { x: 0, y: 0 };
-    }
+    dashboard.addPanel(newPanel);
+    dashboard.removePanel(this.props.panel);
+
+    this.moveToEdit(newPanel);
+  };
+
+  onPasteCopiedPanel = panelPluginInfo => {
+    const dashboard = this.props.dashboard;
+    const { gridPos } = this.props.panel;
+
+    const newPanel: any = {
+      type: panelPluginInfo.id,
+      title: 'Panel Title',
+      gridPos: { x: gridPos.x, y: gridPos.y, w: gridPos.w, h: gridPos.h },
+    };
 
 
     // apply panel template / defaults
     // apply panel template / defaults
     if (panelPluginInfo.defaults) {
     if (panelPluginInfo.defaults) {
       _.defaults(newPanel, panelPluginInfo.defaults);
       _.defaults(newPanel, panelPluginInfo.defaults);
-      newPanel.gridPos.w = panelPluginInfo.defaults.gridPos.w;
-      newPanel.gridPos.h = panelPluginInfo.defaults.gridPos.h;
       newPanel.title = panelPluginInfo.defaults.title;
       newPanel.title = panelPluginInfo.defaults.title;
       store.delete(LS_PANEL_COPY_KEY);
       store.delete(LS_PANEL_COPY_KEY);
     }
     }
@@ -120,133 +112,44 @@ export class AddPanelPanel extends React.Component<AddPanelPanelProps, AddPanelP
     dashboard.removePanel(this.props.panel);
     dashboard.removePanel(this.props.panel);
   };
   };
 
 
-  handleCloseAddPanel(evt) {
-    evt.preventDefault();
-    this.props.dashboard.removePanel(this.props.dashboard.panels[0]);
-  }
-
-  renderText(text: string) {
-    const searchWords = this.state.filter.split('');
-    return <Highlighter highlightClassName="highlight-search-match" textToHighlight={text} searchWords={searchWords} />;
-  }
-
-  renderPanelItem(panel, index) {
-    return (
-      <div key={index} className="add-panel__item" onClick={() => this.onAddPanel(panel)} title={panel.name}>
-        <img className="add-panel__item-img" src={panel.info.logos.small} />
-        <div className="add-panel__item-name">{this.renderText(panel.name)}</div>
-      </div>
-    );
-  }
-
-  noCopiedPanelPlugins() {
-    return <div className="add-panel__no-panels">No copied panels yet.</div>;
-  }
-
-  filterChange(evt) {
-    this.setState({
-      filter: evt.target.value,
-      panelPlugins: this.getPanelPlugins(evt.target.value),
-      copiedPanelPlugins: this.getCopiedPanelPlugins(evt.target.value),
-    });
-  }
-
-  filterKeyPress(evt) {
-    if (evt.key === 'Enter') {
-      const panel = _.head(this.state.panelPlugins);
-      if (panel) {
-        this.onAddPanel(panel);
-      }
-    }
-  }
-
-  filterPanels(panels, filter) {
-    const regex = new RegExp(filter, 'i');
-    return panels.filter(panel => {
-      return regex.test(panel.name);
-    });
-  }
+  onCreateNewRow = () => {
+    const dashboard = this.props.dashboard;
 
 
-  openCopy() {
-    this.setState({
-      tab: 'Copy',
-      filter: '',
-      panelPlugins: this.getPanelPlugins(''),
-      copiedPanelPlugins: this.getCopiedPanelPlugins(''),
-    });
-  }
+    const newRow: any = {
+      type: 'row',
+      title: 'Row title',
+      gridPos: { x: 0, y: 0 },
+    };
 
 
-  openAdd() {
-    this.setState({
-      tab: 'Add',
-      filter: '',
-      panelPlugins: this.getPanelPlugins(''),
-      copiedPanelPlugins: this.getCopiedPanelPlugins(''),
-    });
-  }
+    dashboard.addPanel(newRow);
+    dashboard.removePanel(this.props.panel);
+  };
 
 
   render() {
   render() {
-    const addClass = classNames({
-      'active active--panel': this.state.tab === 'Add',
-      '': this.state.tab === 'Copy',
-    });
-
-    const copyClass = classNames({
-      '': this.state.tab === 'Add',
-      'active active--panel': this.state.tab === 'Copy',
-    });
-
-    let panelTab;
-
-    if (this.state.tab === 'Add') {
-      panelTab = this.state.panelPlugins.map(this.renderPanelItem);
-    } else if (this.state.tab === 'Copy') {
-      if (this.state.copiedPanelPlugins.length > 0) {
-        panelTab = this.state.copiedPanelPlugins.map(this.renderPanelItem);
-      } else {
-        panelTab = this.noCopiedPanelPlugins();
-      }
+    let addCopyButton;
+
+    if (this.state.copiedPanelPlugins.length === 1) {
+      addCopyButton = this.copyButton(this.state.copiedPanelPlugins[0]);
     }
     }
 
 
     return (
     return (
       <div className="panel-container add-panel-container">
       <div className="panel-container add-panel-container">
         <div className="add-panel">
         <div className="add-panel">
-          <div className="add-panel__header">
+          <div className="add-panel__header grid-drag-handle">
             <i className="gicon gicon-add-panel" />
             <i className="gicon gicon-add-panel" />
-            <span className="add-panel__title">New Panel</span>
-            <ul className="gf-tabs">
-              <li className="gf-tabs-item">
-                <div className={'gf-tabs-link pointer ' + addClass} onClick={this.openAdd.bind(this)}>
-                  Add
-                </div>
-              </li>
-              <li className="gf-tabs-item">
-                <div className={'gf-tabs-link pointer ' + copyClass} onClick={this.openCopy.bind(this)}>
-                  Paste
-                </div>
-              </li>
-            </ul>
             <button className="add-panel__close" onClick={this.handleCloseAddPanel}>
             <button className="add-panel__close" onClick={this.handleCloseAddPanel}>
               <i className="fa fa-close" />
               <i className="fa fa-close" />
             </button>
             </button>
           </div>
           </div>
-          <ScrollBar ref={element => (this.scrollbar = element)} className="add-panel__items">
-            <div className="add-panel__searchbar">
-              <label className="gf-form gf-form--grow gf-form--has-input-icon">
-                <input
-                  type="text"
-                  autoFocus
-                  className="gf-form-input gf-form--grow"
-                  placeholder="Panel Search Filter"
-                  value={this.state.filter}
-                  onChange={this.filterChange.bind(this)}
-                  onKeyPress={this.filterKeyPress.bind(this)}
-                />
-                <i className="gf-form-input-icon fa fa-search" />
-              </label>
-            </div>
-            {panelTab}
-          </ScrollBar>
+          <div className="add-panel-btn-container">
+            <button className="btn-success btn btn-large" onClick={this.onCreateNewPanel}>
+              Edit Panel
+            </button>
+            {addCopyButton}
+            <button className="btn-inverse btn" onClick={this.onCreateNewRow}>
+              Add Row
+            </button>
+          </div>
         </div>
         </div>
       </div>
       </div>
     );
     );

+ 72 - 0
public/app/features/dashboard/dashgrid/AlertTab.tsx

@@ -0,0 +1,72 @@
+import React, { PureComponent } from 'react';
+
+import { getAngularLoader, AngularComponent } from 'app/core/services/AngularLoader';
+import { EditorTabBody } from './EditorTabBody';
+import 'app/features/alerting/AlertTabCtrl';
+
+interface Props {
+  angularPanel?: AngularComponent;
+}
+
+export class AlertTab extends PureComponent<Props> {
+  element: any;
+  component: AngularComponent;
+
+  constructor(props) {
+    super(props);
+  }
+
+  componentDidMount() {
+    if (this.shouldLoadAlertTab()) {
+      this.loadAlertTab();
+    }
+  }
+
+  componentDidUpdate(prevProps: Props) {
+    if (this.shouldLoadAlertTab()) {
+      this.loadAlertTab();
+    }
+  }
+
+  shouldLoadAlertTab() {
+    return this.props.angularPanel && this.element;
+  }
+
+  componentWillUnmount() {
+    if (this.component) {
+      this.component.destroy();
+    }
+  }
+
+  loadAlertTab() {
+    const { angularPanel } = this.props;
+
+    const scope = angularPanel.getScope();
+
+    // When full page reloading in edit mode the angular panel has on fully compiled & instantiated yet
+    if (!scope.$$childHead) {
+      setTimeout(() => {
+        this.forceUpdate();
+      });
+      return;
+    }
+
+    const panelCtrl = scope.$$childHead.ctrl;
+    const loader = getAngularLoader();
+    const template = '<alert-tab />';
+
+    const scopeProps = {
+      ctrl: panelCtrl,
+    };
+
+    this.component = loader.load(this.element, scopeProps, template);
+  }
+
+  render() {
+    return (
+      <EditorTabBody heading="Alert" toolbarItems={[]}>
+        <div ref={element => (this.element = element)} />
+      </EditorTabBody>
+    );
+  }
+}

+ 15 - 14
public/app/features/dashboard/dashgrid/DashboardGrid.tsx

@@ -1,4 +1,5 @@
 import React from 'react';
 import React from 'react';
+import { hot } from 'react-hot-loader';
 import ReactGridLayout from 'react-grid-layout';
 import ReactGridLayout from 'react-grid-layout';
 import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN, GRID_COLUMN_COUNT } from 'app/core/constants';
 import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN, GRID_COLUMN_COUNT } from 'app/core/constants';
 import { DashboardPanel } from './DashboardPanel';
 import { DashboardPanel } from './DashboardPanel';
@@ -8,6 +9,7 @@ import classNames from 'classnames';
 import sizeMe from 'react-sizeme';
 import sizeMe from 'react-sizeme';
 
 
 let lastGridWidth = 1200;
 let lastGridWidth = 1200;
+let ignoreNextWidthChange = false;
 
 
 function GridWrapper({
 function GridWrapper({
   size,
   size,
@@ -24,8 +26,12 @@ function GridWrapper({
   isFullscreen,
   isFullscreen,
 }) {
 }) {
   const width = size.width > 0 ? size.width : lastGridWidth;
   const width = size.width > 0 ? size.width : lastGridWidth;
+
+  // logic to ignore width changes (optimization)
   if (width !== lastGridWidth) {
   if (width !== lastGridWidth) {
-    if (!isFullscreen && Math.abs(width - lastGridWidth) > 8) {
+    if (ignoreNextWidthChange) {
+      ignoreNextWidthChange = false;
+    } else if (!isFullscreen && Math.abs(width - lastGridWidth) > 8) {
       onWidthChange();
       onWidthChange();
       lastGridWidth = width;
       lastGridWidth = width;
     }
     }
@@ -39,7 +45,7 @@ function GridWrapper({
       isResizable={isResizable}
       isResizable={isResizable}
       measureBeforeMount={false}
       measureBeforeMount={false}
       containerPadding={[0, 0]}
       containerPadding={[0, 0]}
-      useCSSTransforms={true}
+      useCSSTransforms={false}
       margin={[GRID_CELL_VMARGIN, GRID_CELL_VMARGIN]}
       margin={[GRID_CELL_VMARGIN, GRID_CELL_VMARGIN]}
       cols={GRID_COLUMN_COUNT}
       cols={GRID_COLUMN_COUNT}
       rowHeight={GRID_CELL_HEIGHT}
       rowHeight={GRID_CELL_HEIGHT}
@@ -61,7 +67,7 @@ export interface DashboardGridProps {
   dashboard: DashboardModel;
   dashboard: DashboardModel;
 }
 }
 
 
-export class DashboardGrid extends React.Component<DashboardGridProps, any> {
+export class DashboardGrid extends React.Component<DashboardGridProps> {
   gridToPanelMap: any;
   gridToPanelMap: any;
   panelMap: { [id: string]: PanelModel };
   panelMap: { [id: string]: PanelModel };
 
 
@@ -73,8 +79,6 @@ export class DashboardGrid extends React.Component<DashboardGridProps, any> {
     this.onDragStop = this.onDragStop.bind(this);
     this.onDragStop = this.onDragStop.bind(this);
     this.onWidthChange = this.onWidthChange.bind(this);
     this.onWidthChange = this.onWidthChange.bind(this);
 
 
-    this.state = { animated: false };
-
     // subscribe to dashboard events
     // subscribe to dashboard events
     const dashboard = this.props.dashboard;
     const dashboard = this.props.dashboard;
     dashboard.on('panel-added', this.triggerForceUpdate.bind(this));
     dashboard.on('panel-added', this.triggerForceUpdate.bind(this));
@@ -138,7 +142,8 @@ export class DashboardGrid extends React.Component<DashboardGridProps, any> {
   }
   }
 
 
   onViewModeChanged(payload) {
   onViewModeChanged(payload) {
-    this.setState({ animated: !payload.fullscreen });
+    ignoreNextWidthChange = true;
+    this.forceUpdate();
   }
   }
 
 
   updateGridPos(item, layout) {
   updateGridPos(item, layout) {
@@ -162,17 +167,11 @@ export class DashboardGrid extends React.Component<DashboardGridProps, any> {
     this.updateGridPos(newItem, layout);
     this.updateGridPos(newItem, layout);
   }
   }
 
 
-  componentDidMount() {
-    setTimeout(() => {
-      this.setState({ animated: true });
-    });
-  }
-
   renderPanels() {
   renderPanels() {
     const panelElements = [];
     const panelElements = [];
 
 
     for (const panel of this.props.dashboard.panels) {
     for (const panel of this.props.dashboard.panels) {
-      const panelClasses = classNames({ panel: true, 'panel--fullscreen': panel.fullscreen });
+      const panelClasses = classNames({ 'react-grid-item--fullscreen': panel.fullscreen });
       panelElements.push(
       panelElements.push(
         <div key={panel.id.toString()} className={panelClasses} id={`panel-${panel.id}`}>
         <div key={panel.id.toString()} className={panelClasses} id={`panel-${panel.id}`}>
           <DashboardPanel
           <DashboardPanel
@@ -191,7 +190,7 @@ export class DashboardGrid extends React.Component<DashboardGridProps, any> {
   render() {
   render() {
     return (
     return (
       <SizedReactLayoutGrid
       <SizedReactLayoutGrid
-        className={classNames({ layout: true, animated: this.state.animated })}
+        className={classNames({ layout: true })}
         layout={this.buildLayout()}
         layout={this.buildLayout()}
         isResizable={this.props.dashboard.meta.canEdit}
         isResizable={this.props.dashboard.meta.canEdit}
         isDraggable={this.props.dashboard.meta.canEdit}
         isDraggable={this.props.dashboard.meta.canEdit}
@@ -207,3 +206,5 @@ export class DashboardGrid extends React.Component<DashboardGridProps, any> {
     );
     );
   }
   }
 }
 }
+
+export default hot(module)(DashboardGrid);

+ 1 - 1
public/app/features/dashboard/dashgrid/DashboardGridDirective.ts

@@ -1,4 +1,4 @@
 import { react2AngularDirective } from 'app/core/utils/react2angular';
 import { react2AngularDirective } from 'app/core/utils/react2angular';
-import { DashboardGrid } from './DashboardGrid';
+import DashboardGrid from './DashboardGrid';
 
 
 react2AngularDirective('dashboardGrid', DashboardGrid, [['dashboard', { watchDepth: 'reference' }]]);
 react2AngularDirective('dashboardGrid', DashboardGrid, [['dashboard', { watchDepth: 'reference' }]]);

+ 92 - 76
public/app/features/dashboard/dashgrid/DashboardPanel.tsx

@@ -1,15 +1,20 @@
 import React, { PureComponent } from 'react';
 import React, { PureComponent } from 'react';
 import config from 'app/core/config';
 import config from 'app/core/config';
-import { PanelModel } from '../panel_model';
-import { DashboardModel } from '../dashboard_model';
+import classNames from 'classnames';
+
 import { getAngularLoader, AngularComponent } from 'app/core/services/AngularLoader';
 import { getAngularLoader, AngularComponent } from 'app/core/services/AngularLoader';
-import { DashboardRow } from './DashboardRow';
-import { AddPanelPanel } from './AddPanelPanel';
 import { importPluginModule } from 'app/features/plugins/plugin_loader';
 import { importPluginModule } from 'app/features/plugins/plugin_loader';
-import { PluginExports, PanelPlugin } from 'app/types/plugins';
+
+import { AddPanelPanel } from './AddPanelPanel';
+import { getPanelPluginNotFound } from './PanelPluginNotFound';
+import { DashboardRow } from './DashboardRow';
 import { PanelChrome } from './PanelChrome';
 import { PanelChrome } from './PanelChrome';
 import { PanelEditor } from './PanelEditor';
 import { PanelEditor } from './PanelEditor';
 
 
+import { PanelModel } from '../panel_model';
+import { DashboardModel } from '../dashboard_model';
+import { PanelPlugin } from 'app/types';
+
 export interface Props {
 export interface Props {
   panel: PanelModel;
   panel: PanelModel;
   dashboard: DashboardModel;
   dashboard: DashboardModel;
@@ -18,28 +23,28 @@ export interface Props {
 }
 }
 
 
 export interface State {
 export interface State {
-  pluginExports: PluginExports;
+  plugin: PanelPlugin;
+  angularPanel: AngularComponent;
 }
 }
 
 
 export class DashboardPanel extends PureComponent<Props, State> {
 export class DashboardPanel extends PureComponent<Props, State> {
-  element: any;
-  angularPanel: AngularComponent;
-  pluginInfo: any;
+  element: HTMLElement;
   specialPanels = {};
   specialPanels = {};
 
 
   constructor(props) {
   constructor(props) {
     super(props);
     super(props);
 
 
     this.state = {
     this.state = {
-      pluginExports: null,
+      plugin: null,
+      angularPanel: null,
     };
     };
 
 
     this.specialPanels['row'] = this.renderRow.bind(this);
     this.specialPanels['row'] = this.renderRow.bind(this);
     this.specialPanels['add-panel'] = this.renderAddPanel.bind(this);
     this.specialPanels['add-panel'] = this.renderAddPanel.bind(this);
   }
   }
 
 
-  isSpecial() {
-    return this.specialPanels[this.props.panel.type];
+  isSpecial(pluginId: string) {
+    return this.specialPanels[pluginId];
   }
   }
 
 
   renderRow() {
   renderRow() {
@@ -51,60 +56,60 @@ export class DashboardPanel extends PureComponent<Props, State> {
   }
   }
 
 
   onPluginTypeChanged = (plugin: PanelPlugin) => {
   onPluginTypeChanged = (plugin: PanelPlugin) => {
-    this.props.panel.changeType(plugin.id);
-    this.loadPlugin();
-  };
-
-  onAngularPluginTypeChanged = () => {
-    this.loadPlugin();
+    this.loadPlugin(plugin.id);
   };
   };
 
 
-  loadPlugin() {
-    if (this.isSpecial()) {
+  async loadPlugin(pluginId: string) {
+    if (this.isSpecial(pluginId)) {
       return;
       return;
     }
     }
 
 
+    const { panel } = this.props;
+
     // handle plugin loading & changing of plugin type
     // handle plugin loading & changing of plugin type
-    if (!this.pluginInfo || this.pluginInfo.id !== this.props.panel.type) {
-      this.pluginInfo = config.panels[this.props.panel.type];
+    if (!this.state.plugin || this.state.plugin.id !== pluginId) {
+      const plugin = config.panels[pluginId] || getPanelPluginNotFound(pluginId);
+
+      // remember if this is from an angular panel
+      const fromAngularPanel = this.state.angularPanel != null;
+
+      // unmount angular panel
+      this.cleanUpAngularPanel();
 
 
-      if (this.pluginInfo.exports) {
-        this.cleanUpAngularPanel();
-        this.setState({ pluginExports: this.pluginInfo.exports });
+      if (panel.type !== pluginId) {
+        this.props.panel.changeType(pluginId, fromAngularPanel);
+      }
+
+      if (plugin.exports) {
+        this.setState({ plugin: plugin, angularPanel: null });
       } else {
       } else {
-        importPluginModule(this.pluginInfo.module).then(pluginExports => {
-          this.cleanUpAngularPanel();
-          // cache plugin exports (saves a promise async cycle next time)
-          this.pluginInfo.exports = pluginExports;
-          // update panel state
-          this.setState({ pluginExports: pluginExports });
-        });
+        plugin.exports = await importPluginModule(plugin.module);
+        this.setState({ plugin: plugin, angularPanel: null });
       }
       }
     }
     }
   }
   }
 
 
   componentDidMount() {
   componentDidMount() {
-    this.loadPlugin();
+    this.loadPlugin(this.props.panel.type);
   }
   }
 
 
   componentDidUpdate() {
   componentDidUpdate() {
-    this.loadPlugin();
-
-    // handle angular plugin loading
-    if (!this.element || this.angularPanel) {
+    if (!this.element || this.state.angularPanel) {
       return;
       return;
     }
     }
 
 
     const loader = getAngularLoader();
     const loader = getAngularLoader();
     const template = '<plugin-component type="panel" class="panel-height-helper"></plugin-component>';
     const template = '<plugin-component type="panel" class="panel-height-helper"></plugin-component>';
     const scopeProps = { panel: this.props.panel, dashboard: this.props.dashboard };
     const scopeProps = { panel: this.props.panel, dashboard: this.props.dashboard };
-    this.angularPanel = loader.load(this.element, scopeProps, template);
+    const angularPanel = loader.load(this.element, scopeProps, template);
+
+    this.setState({ angularPanel });
   }
   }
 
 
   cleanUpAngularPanel() {
   cleanUpAngularPanel() {
-    if (this.angularPanel) {
-      this.angularPanel.destroy();
-      this.angularPanel = null;
+    if (this.state.angularPanel) {
+      this.state.angularPanel.destroy();
+      this.element = null;
     }
     }
   }
   }
 
 
@@ -112,50 +117,61 @@ export class DashboardPanel extends PureComponent<Props, State> {
     this.cleanUpAngularPanel();
     this.cleanUpAngularPanel();
   }
   }
 
 
+  onMouseEnter = () => {
+    this.props.dashboard.setPanelFocus(this.props.panel.id);
+  };
+
+  onMouseLeave = () => {
+    this.props.dashboard.setPanelFocus(0);
+  };
+
   renderReactPanel() {
   renderReactPanel() {
-    const { pluginExports } = this.state;
-    const containerClass = this.props.isEditing ? 'panel-editor-container' : 'panel-height-helper';
-    const panelWrapperClass = this.props.isEditing ? 'panel-editor-container__panel' : 'panel-height-helper';
-    // this might look strange with these classes that change when edit, but
-    // I want to try to keep markup (parents) for panel the same in edit mode to avoide unmount / new mount of panel
-    return (
-      <div className={containerClass}>
-        <div className={panelWrapperClass}>
-          <PanelChrome
-            component={pluginExports.PanelComponent}
-            panel={this.props.panel}
-            dashboard={this.props.dashboard}
-          />
-        </div>
-        {this.props.panel.isEditing && (
-          <div className="panel-editor-container__editor">
-            <PanelEditor
-              panel={this.props.panel}
-              panelType={this.props.panel.type}
-              dashboard={this.props.dashboard}
-              onTypeChanged={this.onPluginTypeChanged}
-              pluginExports={pluginExports}
-            />
-          </div>
-        )}
-      </div>
-    );
+    const { dashboard, panel } = this.props;
+    const { plugin } = this.state;
+
+    return <PanelChrome plugin={plugin} panel={panel} dashboard={dashboard} />;
+  }
+
+  renderAngularPanel() {
+    return <div ref={element => (this.element = element)} className="panel-height-helper" />;
   }
   }
 
 
   render() {
   render() {
-    if (this.isSpecial()) {
-      return this.specialPanels[this.props.panel.type]();
+    const { panel, dashboard, isFullscreen, isEditing } = this.props;
+    const { plugin, angularPanel } = this.state;
+
+    if (this.isSpecial(panel.type)) {
+      return this.specialPanels[panel.type]();
     }
     }
 
 
-    if (!this.state.pluginExports) {
+    // if we have not loaded plugin exports yet, wait
+    if (!plugin || !plugin.exports) {
       return null;
       return null;
     }
     }
 
 
-    if (this.state.pluginExports.PanelComponent) {
-      return this.renderReactPanel();
-    }
+    const containerClass = classNames({ 'panel-editor-container': isEditing, 'panel-height-helper': !isEditing });
+    const panelWrapperClass = classNames({
+      'panel-wrapper': true,
+      'panel-wrapper--edit': isEditing,
+      'panel-wrapper--view': isFullscreen && !isEditing,
+    });
 
 
-    // legacy angular rendering
-    return <div ref={element => (this.element = element)} className="panel-height-helper" />;
+    return (
+      <div className={containerClass}>
+        <div className={panelWrapperClass} onMouseEnter={this.onMouseEnter} onMouseLeave={this.onMouseLeave}>
+          {plugin.exports.Panel && this.renderReactPanel()}
+          {plugin.exports.PanelCtrl && this.renderAngularPanel()}
+        </div>
+        {panel.isEditing && (
+          <PanelEditor
+            panel={panel}
+            plugin={plugin}
+            dashboard={dashboard}
+            angularPanel={angularPanel}
+            onTypeChanged={this.onPluginTypeChanged}
+          />
+        )}
+      </div>
+    );
   }
   }
 }
 }

+ 42 - 17
public/app/features/dashboard/dashgrid/DataPanel.tsx

@@ -2,7 +2,10 @@
 import React, { Component } from 'react';
 import React, { Component } from 'react';
 
 
 // Services
 // Services
-import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
+import { getDatasourceSrv, DatasourceSrv } from 'app/features/plugins/datasource_srv';
+
+// Utils
+import kbn from 'app/core/utils/kbn';
 
 
 // Types
 // Types
 import { TimeRange, LoadingState, DataQueryOptions, DataQueryResponse, TimeSeries } from 'app/types';
 import { TimeRange, LoadingState, DataQueryOptions, DataQueryResponse, TimeSeries } from 'app/types';
@@ -19,7 +22,10 @@ export interface Props {
   dashboardId?: number;
   dashboardId?: number;
   isVisible?: boolean;
   isVisible?: boolean;
   timeRange?: TimeRange;
   timeRange?: TimeRange;
+  widthPixels: number;
   refreshCounter: number;
   refreshCounter: number;
+  minInterval?: string;
+  maxDataPoints?: number;
   children: (r: RenderProps) => JSX.Element;
   children: (r: RenderProps) => JSX.Element;
 }
 }
 
 
@@ -36,6 +42,9 @@ export class DataPanel extends Component<Props, State> {
     dashboardId: 1,
     dashboardId: 1,
   };
   };
 
 
+  dataSourceSrv: DatasourceSrv = getDatasourceSrv();
+  isUnmounted = false;
+
   constructor(props: Props) {
   constructor(props: Props) {
     super(props);
     super(props);
 
 
@@ -49,7 +58,11 @@ export class DataPanel extends Component<Props, State> {
   }
   }
 
 
   componentDidMount() {
   componentDidMount() {
-    console.log('DataPanel mount');
+    this.issueQueries();
+  }
+
+  componentWillUnmount() {
+    this.isUnmounted = true;
   }
   }
 
 
   async componentDidUpdate(prevProps: Props) {
   async componentDidUpdate(prevProps: Props) {
@@ -61,11 +74,11 @@ export class DataPanel extends Component<Props, State> {
   }
   }
 
 
   hasPropsChanged(prevProps: Props) {
   hasPropsChanged(prevProps: Props) {
-    return this.props.refreshCounter !== prevProps.refreshCounter || this.props.isVisible !== prevProps.isVisible;
+    return this.props.refreshCounter !== prevProps.refreshCounter;
   }
   }
 
 
-  issueQueries = async () => {
-    const { isVisible, queries, datasource, panelId, dashboardId, timeRange } = this.props;
+  private issueQueries = async () => {
+    const { isVisible, queries, datasource, panelId, dashboardId, timeRange, widthPixels, maxDataPoints } = this.props;
 
 
     if (!isVisible) {
     if (!isVisible) {
       return;
       return;
@@ -79,8 +92,11 @@ export class DataPanel extends Component<Props, State> {
     this.setState({ loading: LoadingState.Loading });
     this.setState({ loading: LoadingState.Loading });
 
 
     try {
     try {
-      const dataSourceSrv = getDatasourceSrv();
-      const ds = await dataSourceSrv.get(datasource);
+      const ds = await this.dataSourceSrv.get(datasource);
+
+      // TODO interpolate variables
+      const minInterval = this.props.minInterval || ds.interval;
+      const intervalRes = kbn.calculateInterval(timeRange, widthPixels, minInterval);
 
 
       const queryOptions: DataQueryOptions = {
       const queryOptions: DataQueryOptions = {
         timezone: 'browser',
         timezone: 'browser',
@@ -88,10 +104,10 @@ export class DataPanel extends Component<Props, State> {
         dashboardId: dashboardId,
         dashboardId: dashboardId,
         range: timeRange,
         range: timeRange,
         rangeRaw: timeRange.raw,
         rangeRaw: timeRange.raw,
-        interval: '1s',
-        intervalMs: 60000,
+        interval: intervalRes.interval,
+        intervalMs: intervalRes.intervalMs,
         targets: queries,
         targets: queries,
-        maxDataPoints: 500,
+        maxDataPoints: maxDataPoints || widthPixels,
         scopedVars: {},
         scopedVars: {},
         cacheTimeout: null,
         cacheTimeout: null,
       };
       };
@@ -100,6 +116,10 @@ export class DataPanel extends Component<Props, State> {
       const resp = await ds.query(queryOptions);
       const resp = await ds.query(queryOptions);
       console.log('Issuing DataPanel query Resp', resp);
       console.log('Issuing DataPanel query Resp', resp);
 
 
+      if (this.isUnmounted) {
+        return;
+      }
+
       this.setState({
       this.setState({
         loading: LoadingState.Done,
         loading: LoadingState.Done,
         response: resp,
         response: resp,
@@ -112,21 +132,26 @@ export class DataPanel extends Component<Props, State> {
   };
   };
 
 
   render() {
   render() {
+    const { queries } = this.props;
     const { response, loading, isFirstLoad } = this.state;
     const { response, loading, isFirstLoad } = this.state;
-    console.log('data panel render');
+
     const timeSeries = response.data;
     const timeSeries = response.data;
 
 
-    if (isFirstLoad && (loading === LoadingState.Loading || loading === LoadingState.NotStarted)) {
+    if (isFirstLoad && loading === LoadingState.Loading) {
+      return this.renderLoadingSpinner();
+    }
+
+    if (!queries.length) {
       return (
       return (
-        <div className="loading">
-          <p>Loading</p>
+        <div className="panel-empty">
+          <p>Add a query to get some data!</p>
         </div>
         </div>
       );
       );
     }
     }
 
 
     return (
     return (
       <>
       <>
-        {this.loadingSpinner}
+        {this.renderLoadingSpinner()}
         {this.props.children({
         {this.props.children({
           timeSeries,
           timeSeries,
           loading,
           loading,
@@ -135,12 +160,12 @@ export class DataPanel extends Component<Props, State> {
     );
     );
   }
   }
 
 
-  private get loadingSpinner(): JSX.Element {
+  private renderLoadingSpinner(): JSX.Element {
     const { loading } = this.state;
     const { loading } = this.state;
 
 
     if (loading === LoadingState.Loading) {
     if (loading === LoadingState.Loading) {
       return (
       return (
-        <div className="panel__loading">
+        <div className="panel-loading">
           <i className="fa fa-spinner fa-spin" />
           <i className="fa fa-spinner fa-spin" />
         </div>
         </div>
       );
       );

+ 31 - 0
public/app/features/dashboard/dashgrid/DataSourceOption.tsx

@@ -0,0 +1,31 @@
+import React, { SFC } from 'react';
+import Tooltip from 'app/core/components/Tooltip/Tooltip';
+
+interface Props {
+  label: string;
+  placeholder?: string;
+  name?: string;
+  value?: string;
+  onChange?: (evt: any) => void;
+  tooltipInfo?: any;
+}
+
+export const DataSourceOptions: SFC<Props> = ({ label, placeholder, name, value, onChange, tooltipInfo }) => {
+  const dsOption = (
+    <div className="gf-form gf-form--flex-end">
+      <label className="gf-form-label">{label}</label>
+      <input
+        type="text"
+        className="gf-form-input width-6"
+        placeholder={placeholder}
+        name={name}
+        spellCheck={false}
+        onBlur={evt => onChange(evt.target.value)}
+      />
+    </div>
+  );
+
+  return tooltipInfo ? <Tooltip content={tooltipInfo}>{dsOption}</Tooltip> : dsOption;
+};
+
+export default DataSourceOptions;

+ 133 - 0
public/app/features/dashboard/dashgrid/EditorTabBody.tsx

@@ -0,0 +1,133 @@
+// Libraries
+import React, { PureComponent } from 'react';
+
+// Components
+import CustomScrollbar from 'app/core/components/CustomScrollbar/CustomScrollbar';
+import { FadeIn } from 'app/core/components/Animations/FadeIn';
+import { PanelOptionSection } from './PanelOptionSection';
+
+interface Props {
+  children: JSX.Element;
+  heading: string;
+  renderToolbar?: () => JSX.Element;
+  toolbarItems?: EditorToolBarView[];
+}
+
+export interface EditorToolBarView {
+  title?: string;
+  heading?: string;
+  imgSrc?: string;
+  icon?: string;
+  disabled?: boolean;
+  onClick?: () => void;
+  render: (closeFunction?: any) => JSX.Element | JSX.Element[];
+}
+
+interface State {
+  openView?: EditorToolBarView;
+  isOpen: boolean;
+  fadeIn: boolean;
+}
+
+export class EditorTabBody extends PureComponent<Props, State> {
+  static defaultProps = {
+    toolbarItems: [],
+  };
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      openView: null,
+      fadeIn: false,
+      isOpen: false,
+    };
+  }
+
+  componentDidMount() {
+    this.setState({ fadeIn: true });
+  }
+
+  onToggleToolBarView = (item: EditorToolBarView) => {
+    this.setState({
+      openView: item,
+      isOpen: !this.state.isOpen,
+    });
+  };
+
+  onCloseOpenView = () => {
+    this.setState({ isOpen: false });
+  };
+
+  static getDerivedStateFromProps(props, state) {
+    if (state.openView) {
+      const activeToolbarItem = props.toolbarItems.find(
+        item => item.title === state.openView.title && item.icon === state.openView.icon
+      );
+      if (activeToolbarItem) {
+        return {
+          ...state,
+          openView: activeToolbarItem,
+        };
+      }
+    }
+    return state;
+  }
+
+  renderButton(view: EditorToolBarView) {
+    const onClick = () => {
+      if (view.onClick) {
+        view.onClick();
+      }
+      this.onToggleToolBarView(view);
+    };
+
+    return (
+      <div className="nav-buttons" key={view.title + view.icon}>
+        <button className="btn navbar-button" onClick={onClick} disabled={view.disabled}>
+          {view.icon && <i className={view.icon} />} {view.title}
+        </button>
+      </div>
+    );
+  }
+
+  renderOpenView(view: EditorToolBarView) {
+    return (
+      <PanelOptionSection title={view.title || view.heading} onClose={this.onCloseOpenView}>
+        {view.render()}
+      </PanelOptionSection>
+    );
+  }
+
+  render() {
+    const { children, renderToolbar, heading, toolbarItems } = this.props;
+    const { openView, fadeIn, isOpen } = this.state;
+
+    return (
+      <>
+        <div className="toolbar">
+          <div className="toolbar__heading">{heading}</div>
+          {renderToolbar && renderToolbar()}
+          {toolbarItems.length > 0 && (
+            <>
+              <div className="gf-form--grow" />
+              {toolbarItems.map(item => this.renderButton(item))}
+            </>
+          )}
+        </div>
+        <div className="panel-editor__scroll">
+          <CustomScrollbar autoHide={false}>
+            <div className="panel-editor__content">
+              <FadeIn in={isOpen} duration={200} unmountOnExit={true}>
+                {openView && this.renderOpenView(openView)}
+              </FadeIn>
+              <FadeIn in={fadeIn} duration={50}>
+                {children}
+              </FadeIn>
+            </div>
+          </CustomScrollbar>
+        </div>
+      </>
+    );
+  }
+}

+ 52 - 0
public/app/features/dashboard/dashgrid/GeneralTab.tsx

@@ -0,0 +1,52 @@
+import React, { PureComponent } from 'react';
+
+import { getAngularLoader, AngularComponent } from 'app/core/services/AngularLoader';
+import { EditorTabBody } from './EditorTabBody';
+
+import { PanelModel } from '../panel_model';
+import './../../panel/GeneralTabCtrl';
+
+interface Props {
+  panel: PanelModel;
+}
+
+export class GeneralTab extends PureComponent<Props> {
+  element: any;
+  component: AngularComponent;
+
+  constructor(props) {
+    super(props);
+  }
+
+  componentDidMount() {
+    if (!this.element) {
+      return;
+    }
+
+    const { panel } = this.props;
+
+    const loader = getAngularLoader();
+    const template = '<panel-general-tab />';
+    const scopeProps = {
+      ctrl: {
+        panel: panel,
+      },
+    };
+
+    this.component = loader.load(this.element, scopeProps, template);
+  }
+
+  componentWillUnmount() {
+    if (this.component) {
+      this.component.destroy();
+    }
+  }
+
+  render() {
+    return (
+      <EditorTabBody heading="Panel Options" toolbarItems={[]}>
+        <div ref={element => (this.element = element)} />
+      </EditorTabBody>
+    );
+  }
+}

+ 71 - 0
public/app/features/dashboard/dashgrid/KeyboardNavigation.tsx

@@ -0,0 +1,71 @@
+import React, { KeyboardEvent, Component } from 'react';
+
+interface State {
+  selected: number;
+}
+
+export interface KeyboardNavigationProps {
+  onKeyDown: (evt: KeyboardEvent<EventTarget>, maxSelectedIndex: number, onEnterAction: () => void) => void;
+  onMouseEnter: (select: number) => void;
+  selected: number;
+}
+
+interface Props {
+  render: (injectProps: any) => void;
+}
+
+class KeyboardNavigation extends Component<Props, State> {
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      selected: 0,
+    };
+  }
+
+  goToNext = (maxSelectedIndex: number) => {
+    const nextIndex = this.state.selected >= maxSelectedIndex ? 0 : this.state.selected + 1;
+    this.setState({
+      selected: nextIndex,
+    });
+  };
+
+  goToPrev = (maxSelectedIndex: number) => {
+    const nextIndex = this.state.selected <= 0 ? maxSelectedIndex : this.state.selected - 1;
+    this.setState({
+      selected: nextIndex,
+    });
+  };
+
+  onKeyDown = (evt: KeyboardEvent, maxSelectedIndex: number, onEnterAction: any) => {
+    if (evt.key === 'ArrowDown') {
+      evt.preventDefault();
+      this.goToNext(maxSelectedIndex);
+    }
+    if (evt.key === 'ArrowUp') {
+      evt.preventDefault();
+      this.goToPrev(maxSelectedIndex);
+    }
+    if (evt.key === 'Enter' && onEnterAction) {
+      onEnterAction();
+    }
+  };
+
+  onMouseEnter = (mouseEnterIndex: number) => {
+    this.setState({
+      selected: mouseEnterIndex,
+    });
+  };
+
+  render() {
+    const injectProps = {
+      onKeyDown: this.onKeyDown,
+      onMouseEnter: this.onMouseEnter,
+      selected: this.state.selected,
+    };
+
+    return <>{this.props.render({ ...injectProps })}</>;
+  }
+}
+
+export default KeyboardNavigation;

+ 76 - 44
public/app/features/dashboard/dashgrid/PanelChrome.tsx

@@ -1,31 +1,39 @@
 // Libraries
 // Libraries
-import React, { ComponentClass, PureComponent } from 'react';
+import React, { PureComponent } from 'react';
+import { AutoSizer } from 'react-virtualized';
 
 
 // Services
 // Services
-import { getTimeSrv } from '../time_srv';
+import { getTimeSrv, TimeSrv } from '../time_srv';
 
 
 // Components
 // Components
 import { PanelHeader } from './PanelHeader/PanelHeader';
 import { PanelHeader } from './PanelHeader/PanelHeader';
 import { DataPanel } from './DataPanel';
 import { DataPanel } from './DataPanel';
 
 
+// Utils
+import { applyPanelTimeOverrides } from 'app/features/dashboard/utils/panel';
+import { PANEL_HEADER_HEIGHT } from 'app/core/constants';
+
 // Types
 // Types
 import { PanelModel } from '../panel_model';
 import { PanelModel } from '../panel_model';
 import { DashboardModel } from '../dashboard_model';
 import { DashboardModel } from '../dashboard_model';
-import { TimeRange, PanelProps } from 'app/types';
+import { PanelPlugin, TimeRange } from 'app/types';
 
 
 export interface Props {
 export interface Props {
   panel: PanelModel;
   panel: PanelModel;
   dashboard: DashboardModel;
   dashboard: DashboardModel;
-  component: ComponentClass<PanelProps>;
+  plugin: PanelPlugin;
 }
 }
 
 
 export interface State {
 export interface State {
   refreshCounter: number;
   refreshCounter: number;
   renderCounter: number;
   renderCounter: number;
+  timeInfo?: string;
   timeRange?: TimeRange;
   timeRange?: TimeRange;
 }
 }
 
 
 export class PanelChrome extends PureComponent<Props, State> {
 export class PanelChrome extends PureComponent<Props, State> {
+  timeSrv: TimeSrv = getTimeSrv();
+
   constructor(props) {
   constructor(props) {
     super(props);
     super(props);
 
 
@@ -46,22 +54,25 @@ export class PanelChrome extends PureComponent<Props, State> {
   }
   }
 
 
   onRefresh = () => {
   onRefresh = () => {
-    const timeSrv = getTimeSrv();
-    const timeRange = timeSrv.timeRange();
+    console.log('onRefresh');
+    if (!this.isVisible) {
+      return;
+    }
+
+    const { panel } = this.props;
+    const timeData = applyPanelTimeOverrides(panel, this.timeSrv.timeRange());
 
 
-    this.setState(prevState => ({
-      ...prevState,
+    this.setState({
       refreshCounter: this.state.refreshCounter + 1,
       refreshCounter: this.state.refreshCounter + 1,
-      timeRange: timeRange,
-    }));
+      timeRange: timeData.timeRange,
+      timeInfo: timeData.timeInfo,
+    });
   };
   };
 
 
   onRender = () => {
   onRender = () => {
-    console.log('onRender');
-    this.setState(prevState => ({
-      ...prevState,
+    this.setState({
       renderCounter: this.state.renderCounter + 1,
       renderCounter: this.state.renderCounter + 1,
-    }));
+    });
   };
   };
 
 
   get isVisible() {
   get isVisible() {
@@ -69,39 +80,60 @@ export class PanelChrome extends PureComponent<Props, State> {
   }
   }
 
 
   render() {
   render() {
-    const { panel, dashboard } = this.props;
-    const { refreshCounter, timeRange, renderCounter } = this.state;
+    const { panel, dashboard, plugin } = this.props;
+    const { refreshCounter, timeRange, timeInfo, renderCounter } = this.state;
 
 
-    const { datasource, targets } = panel;
-    const PanelComponent = this.props.component;
+    const { datasource, targets, transparent } = panel;
+    const PanelComponent = plugin.exports.Panel;
+    const containerClassNames = `panel-container panel-container--absolute ${transparent ? 'panel-transparent' : ''}`;
 
 
-    console.log('panelChrome render');
     return (
     return (
-      <div className="panel-container">
-        <PanelHeader panel={panel} dashboard={dashboard} />
-        <div className="panel-content">
-          <DataPanel
-            datasource={datasource}
-            queries={targets}
-            timeRange={timeRange}
-            isVisible={this.isVisible}
-            refreshCounter={refreshCounter}
-          >
-            {({ loading, timeSeries }) => {
-              console.log('panelcrome inner render');
-              return (
-                <PanelComponent
-                  loading={loading}
-                  timeSeries={timeSeries}
-                  timeRange={timeRange}
-                  options={panel.getOptions()}
-                  renderCounter={renderCounter}
-                />
-              );
-            }}
-          </DataPanel>
-        </div>
-      </div>
+      <AutoSizer>
+        {({ width, height }) => {
+          if (width === 0) {
+            return null;
+          }
+
+          return (
+            <div className={containerClassNames}>
+              <PanelHeader
+                panel={panel}
+                dashboard={dashboard}
+                timeInfo={timeInfo}
+                title={panel.title}
+                description={panel.description}
+                scopedVars={panel.scopedVars}
+                links={panel.links}
+              />
+
+              <DataPanel
+                datasource={datasource}
+                queries={targets}
+                timeRange={timeRange}
+                isVisible={this.isVisible}
+                widthPixels={width}
+                refreshCounter={refreshCounter}
+              >
+                {({ loading, timeSeries }) => {
+                  return (
+                    <div className="panel-content">
+                      <PanelComponent
+                        loading={loading}
+                        timeSeries={timeSeries}
+                        timeRange={timeRange}
+                        options={panel.getOptions(plugin.exports.PanelDefaults)}
+                        width={width}
+                        height={height - PANEL_HEADER_HEIGHT}
+                        renderCounter={renderCounter}
+                      />
+                    </div>
+                  );
+                }}
+              </DataPanel>
+            </div>
+          );
+        }}
+      </AutoSizer>
     );
     );
   }
   }
 }
 }

+ 87 - 70
public/app/features/dashboard/dashgrid/PanelEditor.tsx

@@ -2,73 +2,37 @@ import React, { PureComponent } from 'react';
 import classNames from 'classnames';
 import classNames from 'classnames';
 
 
 import { QueriesTab } from './QueriesTab';
 import { QueriesTab } from './QueriesTab';
-import { VizTypePicker } from './VizTypePicker';
+import { VisualizationTab } from './VisualizationTab';
+import { GeneralTab } from './GeneralTab';
+import { AlertTab } from './AlertTab';
 
 
-import { store } from 'app/store/configureStore';
+import config from 'app/core/config';
+import { store } from 'app/store/store';
 import { updateLocation } from 'app/core/actions';
 import { updateLocation } from 'app/core/actions';
+import { AngularComponent } from 'app/core/services/AngularLoader';
 
 
 import { PanelModel } from '../panel_model';
 import { PanelModel } from '../panel_model';
 import { DashboardModel } from '../dashboard_model';
 import { DashboardModel } from '../dashboard_model';
-import { PanelPlugin, PluginExports } from 'app/types/plugins';
+import { PanelPlugin } from 'app/types/plugins';
+
+import Tooltip from 'app/core/components/Tooltip/Tooltip';
 
 
 interface PanelEditorProps {
 interface PanelEditorProps {
   panel: PanelModel;
   panel: PanelModel;
   dashboard: DashboardModel;
   dashboard: DashboardModel;
-  panelType: string;
-  pluginExports: PluginExports;
+  plugin: PanelPlugin;
+  angularPanel?: AngularComponent;
   onTypeChanged: (newType: PanelPlugin) => void;
   onTypeChanged: (newType: PanelPlugin) => void;
 }
 }
 
 
 interface PanelEditorTab {
 interface PanelEditorTab {
   id: string;
   id: string;
   text: string;
   text: string;
-  icon: string;
 }
 }
 
 
 export class PanelEditor extends PureComponent<PanelEditorProps> {
 export class PanelEditor extends PureComponent<PanelEditorProps> {
-  tabs: PanelEditorTab[];
-
   constructor(props) {
   constructor(props) {
     super(props);
     super(props);
-
-    this.tabs = [
-      { id: 'queries', text: 'Queries', icon: 'fa fa-database' },
-      { id: 'visualization', text: 'Visualization', icon: 'fa fa-line-chart' },
-    ];
-  }
-
-  renderQueriesTab() {
-    return <QueriesTab panel={this.props.panel} dashboard={this.props.dashboard} />;
-  }
-
-  renderPanelOptions() {
-    const { pluginExports, panel } = this.props;
-
-    if (pluginExports.PanelOptionsComponent) {
-      const OptionsComponent = pluginExports.PanelOptionsComponent;
-      return <OptionsComponent options={panel.getOptions()} onChange={this.onPanelOptionsChanged} />;
-    } else {
-      return <p>Visualization has no options</p>;
-    }
-  }
-
-  onPanelOptionsChanged = (options: any) => {
-    this.props.panel.updateOptions(options);
-    this.forceUpdate();
-  };
-
-  renderVizTab() {
-    return (
-      <div className="viz-editor">
-        <div className="viz-editor-col1">
-          <VizTypePicker currentType={this.props.panel.type} onTypeChanged={this.props.onTypeChanged} />
-        </div>
-        <div className="viz-editor-col2">
-          <h5 className="page-heading">Options</h5>
-          {this.renderPanelOptions()}
-        </div>
-      </div>
-    );
   }
   }
 
 
   onChangeTab = (tab: PanelEditorTab) => {
   onChangeTab = (tab: PanelEditorTab) => {
@@ -81,28 +45,79 @@ export class PanelEditor extends PureComponent<PanelEditorProps> {
     this.forceUpdate();
     this.forceUpdate();
   };
   };
 
 
+  renderCurrentTab(activeTab: string) {
+    const { panel, dashboard, onTypeChanged, plugin, angularPanel } = this.props;
+
+    switch (activeTab) {
+      case 'advanced':
+        return <GeneralTab panel={panel} />;
+      case 'queries':
+        return <QueriesTab panel={panel} dashboard={dashboard} />;
+      case 'alert':
+        return <AlertTab angularPanel={angularPanel} />;
+      case 'visualization':
+        return (
+          <VisualizationTab
+            panel={panel}
+            dashboard={dashboard}
+            plugin={plugin}
+            onTypeChanged={onTypeChanged}
+            angularPanel={angularPanel}
+          />
+        );
+      default:
+        return null;
+    }
+  }
+
   render() {
   render() {
-    const { location } = store.getState();
-    const activeTab = location.query.tab || 'queries';
+    const { plugin } = this.props;
+    let activeTab = store.getState().location.query.tab || 'queries';
 
 
-    return (
-      <div className="tabbed-view tabbed-view--new">
-        <div className="tabbed-view-header">
-          <ul className="gf-tabs">
-            {this.tabs.map(tab => {
-              return <TabItem tab={tab} activeTab={activeTab} onClick={this.onChangeTab} key={tab.id} />;
-            })}
-          </ul>
-
-          <button className="tabbed-view-close-btn" ng-click="ctrl.exitFullscreen();">
-            <i className="fa fa-remove" />
-          </button>
-        </div>
+    const tabs: PanelEditorTab[] = [
+      { id: 'queries', text: 'Queries' },
+      { id: 'visualization', text: 'Visualization' },
+      { id: 'advanced', text: 'Panel Options' },
+    ];
+
+    // handle panels that do not have queries tab
+    if (plugin.exports.PanelCtrl) {
+      if (!plugin.exports.PanelCtrl.prototype.onDataReceived) {
+        // remove queries tab
+        tabs.shift();
+        // switch tab
+        if (activeTab === 'queries') {
+          activeTab = 'visualization';
+        }
+      }
+    }
 
 
-        <div className="tabbed-view-body">
-          {activeTab === 'queries' && this.renderQueriesTab()}
-          {activeTab === 'visualization' && this.renderVizTab()}
+    if (config.alertingEnabled && plugin.id === 'graph') {
+      tabs.push({
+        id: 'alert',
+        text: 'Alert',
+      });
+    }
+
+    return (
+      <div className="panel-editor-container__editor">
+        {
+          // <div className="panel-editor__close">
+          //   <i className="fa fa-arrow-left" />
+          // </div>
+          // <div className="panel-editor-resizer">
+          //   <div className="panel-editor-resizer__handle">
+          //     <div className="panel-editor-resizer__handle-dots" />
+          //   </div>
+          // </div>
+        }
+
+        <div className="panel-editor-tabs">
+          {tabs.map(tab => {
+            return <TabItem tab={tab} activeTab={activeTab} onClick={this.onChangeTab} key={tab.id} />;
+          })}
         </div>
         </div>
+        <div className="panel-editor__right">{this.renderCurrentTab(activeTab)}</div>
       </div>
       </div>
     );
     );
   }
   }
@@ -116,15 +131,17 @@ interface TabItemParams {
 
 
 function TabItem({ tab, activeTab, onClick }: TabItemParams) {
 function TabItem({ tab, activeTab, onClick }: TabItemParams) {
   const tabClasses = classNames({
   const tabClasses = classNames({
-    'gf-tabs-link': true,
+    'panel-editor-tabs__link': true,
     active: activeTab === tab.id,
     active: activeTab === tab.id,
   });
   });
 
 
   return (
   return (
-    <li className="gf-tabs-item" key={tab.id}>
-      <a className={tabClasses} onClick={() => onClick(tab)}>
-        <i className={tab.icon} /> {tab.text}
+    <div className="panel-editor-tabs__item" onClick={() => onClick(tab)}>
+      <a className={tabClasses}>
+        <Tooltip content={`${tab.text}`} className="popper__manager--block" placement="auto">
+          <i className={`gicon gicon-${tab.id}${activeTab === tab.id ? '-active' : ''}`} />
+        </Tooltip>
       </a>
       </a>
-    </li>
+    </div>
   );
   );
 }
 }

+ 63 - 26
public/app/features/dashboard/dashgrid/PanelHeader/PanelHeader.tsx

@@ -1,51 +1,88 @@
-import React, { PureComponent } from 'react';
+import React, { Component } from 'react';
 import classNames from 'classnames';
 import classNames from 'classnames';
 
 
+import PanelHeaderCorner from './PanelHeaderCorner';
 import { PanelHeaderMenu } from './PanelHeaderMenu';
 import { PanelHeaderMenu } from './PanelHeaderMenu';
 
 
 import { DashboardModel } from 'app/features/dashboard/dashboard_model';
 import { DashboardModel } from 'app/features/dashboard/dashboard_model';
 import { PanelModel } from 'app/features/dashboard/panel_model';
 import { PanelModel } from 'app/features/dashboard/panel_model';
+import { ClickOutsideWrapper } from 'app/core/components/ClickOutsideWrapper/ClickOutsideWrapper';
 
 
 export interface Props {
 export interface Props {
   panel: PanelModel;
   panel: PanelModel;
   dashboard: DashboardModel;
   dashboard: DashboardModel;
+  timeInfo: string;
+  title?: string;
+  description?: string;
+  scopedVars?: string;
+  links?: [];
 }
 }
 
 
-export class PanelHeader extends PureComponent<Props> {
+interface State {
+  panelMenuOpen: boolean;
+}
+
+export class PanelHeader extends Component<Props, State> {
+  state = {
+    panelMenuOpen: false,
+  };
+
+  onMenuToggle = event => {
+    event.stopPropagation();
+
+    this.setState(prevState => ({
+      panelMenuOpen: !prevState.panelMenuOpen,
+    }));
+  };
+
+  closeMenu = () => {
+    this.setState({
+      panelMenuOpen: false,
+    });
+  };
+
   render() {
   render() {
     const isFullscreen = false;
     const isFullscreen = false;
     const isLoading = false;
     const isLoading = false;
     const panelHeaderClass = classNames({ 'panel-header': true, 'grid-drag-handle': !isFullscreen });
     const panelHeaderClass = classNames({ 'panel-header': true, 'grid-drag-handle': !isFullscreen });
-    const { panel, dashboard } = this.props;
-
+    const { panel, dashboard, timeInfo } = this.props;
     return (
     return (
-      <div className={panelHeaderClass}>
-        <span className="panel-info-corner">
-          <i className="fa" />
-          <span className="panel-info-corner-inner" />
-        </span>
-
-        {isLoading && (
-          <span className="panel-loading">
-            <i className="fa fa-spinner fa-spin" />
-          </span>
-        )}
-
-        <div className="panel-title-container">
-          <div className="panel-title">
-            <span className="icon-gf panel-alert-icon" />
-            <span className="panel-title-text" data-toggle="dropdown">
-              {panel.title} <span className="fa fa-caret-down panel-menu-toggle" />
+      <>
+        <PanelHeaderCorner
+          panel={panel}
+          title={panel.title}
+          description={panel.description}
+          scopedVars={panel.scopedVars}
+          links={panel.links}
+        />
+        <div className={panelHeaderClass}>
+          {isLoading && (
+            <span className="panel-loading">
+              <i className="fa fa-spinner fa-spin" />
             </span>
             </span>
+          )}
+          <div className="panel-title-container" onClick={this.onMenuToggle}>
+            <div className="panel-title">
+              <span className="icon-gf panel-alert-icon" />
+              <span className="panel-title-text">
+                {panel.title} <span className="fa fa-caret-down panel-menu-toggle" />
+              </span>
 
 
-            <PanelHeaderMenu panel={panel} dashboard={dashboard} />
+              {this.state.panelMenuOpen && (
+                <ClickOutsideWrapper onClick={this.closeMenu}>
+                  <PanelHeaderMenu panel={panel} dashboard={dashboard} />
+                </ClickOutsideWrapper>
+              )}
 
 
-            <span className="panel-time-info">
-              <i className="fa fa-clock-o" /> 4m
-            </span>
+              {timeInfo && (
+                <span className="panel-time-info">
+                  <i className="fa fa-clock-o" /> {timeInfo}
+                </span>
+              )}
+            </div>
           </div>
           </div>
         </div>
         </div>
-      </div>
+      </>
     );
     );
   }
   }
 }
 }

+ 94 - 0
public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderCorner.tsx

@@ -0,0 +1,94 @@
+import React, { Component } from 'react';
+import { PanelModel } from 'app/features/dashboard/panel_model';
+import Tooltip from 'app/core/components/Tooltip/Tooltip';
+import templateSrv from 'app/features/templating/template_srv';
+import { LinkSrv } from 'app/features/dashboard/panellinks/link_srv';
+import { getTimeSrv, TimeSrv } from 'app/features/dashboard/time_srv';
+import Remarkable from 'remarkable';
+
+enum InfoModes {
+  Error = 'Error',
+  Info = 'Info',
+  Links = 'Links',
+}
+
+interface Props {
+  panel: PanelModel;
+  title?: string;
+  description?: string;
+  scopedVars?: string;
+  links?: [];
+}
+
+export class PanelHeaderCorner extends Component<Props> {
+  timeSrv: TimeSrv = getTimeSrv();
+
+  getInfoMode = () => {
+    const { panel } = this.props;
+    if (!!panel.description) {
+      return InfoModes.Info;
+    }
+    if (panel.links && panel.links.length) {
+      return InfoModes.Links;
+    }
+
+    return undefined;
+  };
+
+  getInfoContent = (): JSX.Element => {
+    const { panel } = this.props;
+    const markdown = panel.description;
+    const linkSrv = new LinkSrv(templateSrv, this.timeSrv);
+    const interpolatedMarkdown = templateSrv.replace(markdown, panel.scopedVars);
+    const remarkableInterpolatedMarkdown = new Remarkable().render(interpolatedMarkdown);
+
+    const html = (
+      <div className="markdown-html">
+        <div dangerouslySetInnerHTML={{ __html: remarkableInterpolatedMarkdown }} />
+        {panel.links &&
+          panel.links.length > 0 && (
+            <ul className="text-left">
+              {panel.links.map((link, idx) => {
+                const info = linkSrv.getPanelLinkAnchorInfo(link, panel.scopedVars);
+                return (
+                  <li key={idx}>
+                    <a className="panel-menu-link" href={info.href} target={info.target}>
+                      {info.title}
+                    </a>
+                  </li>
+                );
+              })}
+            </ul>
+          )}
+      </div>
+    );
+
+    return html;
+  };
+
+  render() {
+    const infoMode: InfoModes | undefined = this.getInfoMode();
+
+    if (!infoMode) {
+      return null;
+    }
+
+    return (
+      <>
+        {infoMode === InfoModes.Info || infoMode === InfoModes.Links ? (
+          <Tooltip
+            content={this.getInfoContent}
+            className="popper__manager--block"
+            refClassName={`panel-info-corner panel-info-corner--${infoMode.toLowerCase()}`}
+            placement="bottom-start"
+          >
+            <i className="fa" />
+            <span className="panel-info-corner-inner" />
+          </Tooltip>
+        ) : null}
+      </>
+    );
+  }
+}
+
+export default PanelHeaderCorner;

+ 2 - 1
public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenu.tsx

@@ -35,6 +35,7 @@ export class PanelHeaderMenu extends PureComponent<Props> {
   render() {
   render() {
     const { dashboard, panel } = this.props;
     const { dashboard, panel } = this.props;
     const menu = getPanelMenu(dashboard, panel);
     const menu = getPanelMenu(dashboard, panel);
-    return <div className="panel-menu-container dropdown">{this.renderItems(menu)}</div>;
+
+    return <div className="panel-menu-container dropdown open">{this.renderItems(menu)}</div>;
   }
   }
 }
 }

+ 26 - 0
public/app/features/dashboard/dashgrid/PanelOptionSection.tsx

@@ -0,0 +1,26 @@
+// Libraries
+import React, { SFC } from 'react';
+
+interface Props {
+  title?: string;
+  onClose?: () => void;
+  children: JSX.Element | JSX.Element[];
+}
+
+export const PanelOptionSection: SFC<Props> = props => {
+  return (
+    <div className="panel-option-section">
+      {props.title && (
+        <div className="panel-option-section__header">
+          {props.title}
+          {props.onClose && (
+            <button className="btn btn-link" onClick={props.onClose}>
+              <i className="fa fa-remove" />
+            </button>
+          )}
+        </div>
+      )}
+      <div className="panel-option-section__body">{props.children}</div>
+    </div>
+  );
+};

+ 64 - 0
public/app/features/dashboard/dashgrid/PanelPluginNotFound.tsx

@@ -0,0 +1,64 @@
+import _ from 'lodash';
+import React, { PureComponent } from 'react';
+import { PanelPlugin, PanelProps } from 'app/types';
+
+interface Props {
+  pluginId: string;
+}
+
+class PanelPluginNotFound extends PureComponent<Props> {
+  constructor(props) {
+    super(props);
+  }
+
+  render() {
+    const style = {
+      display: 'flex',
+      alignItems: 'center',
+      textAlign: 'center' as 'center',
+      height: '100%',
+    };
+
+    return (
+      <div style={style}>
+        <div className="alert alert-error" style={{ margin: '0 auto' }}>
+          Panel plugin with id {this.props.pluginId} could not be found
+        </div>
+      </div>
+    );
+  }
+}
+
+export function getPanelPluginNotFound(id: string): PanelPlugin {
+  const NotFound = class NotFound extends PureComponent<PanelProps> {
+    render() {
+      return <PanelPluginNotFound pluginId={id} />;
+    }
+  };
+
+  return {
+    id: id,
+    name: id,
+    sort: 100,
+    module: '',
+    baseUrl: '',
+    info: {
+      author: {
+        name: '',
+      },
+      description: '',
+      links: [],
+      logos: {
+        large: '',
+        small: '',
+      },
+      screenshots: [],
+      updated: '',
+      version: '',
+    },
+
+    exports: {
+      Panel: NotFound,
+    },
+  };
+}

+ 244 - 12
public/app/features/dashboard/dashgrid/QueriesTab.tsx

@@ -1,24 +1,79 @@
 // Libraries
 // Libraries
-import React, { PureComponent } from 'react';
+import React, { SFC, PureComponent } from 'react';
+import Remarkable from 'remarkable';
+import _ from 'lodash';
 
 
-// Services & utils
+// Components
+import './../../panel/metrics_tab';
+import { EditorTabBody } from './EditorTabBody';
+import { DataSourcePicker } from 'app/core/components/Select/DataSourcePicker';
+import { QueryInspector } from './QueryInspector';
+import { QueryOptions } from './QueryOptions';
+import { AngularQueryComponentScope } from 'app/features/panel/metrics_tab';
+import { PanelOptionSection } from './PanelOptionSection';
+
+// Services
+import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
+import { getBackendSrv, BackendSrv } from 'app/core/services/backend_srv';
 import { getAngularLoader, AngularComponent } from 'app/core/services/AngularLoader';
 import { getAngularLoader, AngularComponent } from 'app/core/services/AngularLoader';
+import config from 'app/core/config';
 
 
 // Types
 // Types
 import { PanelModel } from '../panel_model';
 import { PanelModel } from '../panel_model';
 import { DashboardModel } from '../dashboard_model';
 import { DashboardModel } from '../dashboard_model';
+import { DataSourceSelectItem, DataQuery } from 'app/types';
 
 
 interface Props {
 interface Props {
   panel: PanelModel;
   panel: PanelModel;
   dashboard: DashboardModel;
   dashboard: DashboardModel;
 }
 }
 
 
-export class QueriesTab extends PureComponent<Props> {
-  element: any;
+interface State {
+  currentDS: DataSourceSelectItem;
+  helpContent: JSX.Element;
+  isLoadingHelp: boolean;
+  isPickerOpen: boolean;
+  isAddingMixed: boolean;
+}
+
+interface LoadingPlaceholderProps {
+  text: string;
+}
+
+const LoadingPlaceholder: SFC<LoadingPlaceholderProps> = ({ text }) => <h2>{text}</h2>;
+
+export class QueriesTab extends PureComponent<Props, State> {
+  element: HTMLElement;
   component: AngularComponent;
   component: AngularComponent;
+  datasources: DataSourceSelectItem[] = getDatasourceSrv().getMetricSources();
+  backendSrv: BackendSrv = getBackendSrv();
 
 
   constructor(props) {
   constructor(props) {
     super(props);
     super(props);
+    const { panel } = props;
+
+    this.state = {
+      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,
+    };
   }
   }
 
 
   componentDidMount() {
   componentDidMount() {
@@ -26,16 +81,10 @@ export class QueriesTab extends PureComponent<Props> {
       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);
@@ -47,7 +96,190 @@ export class QueriesTab extends PureComponent<Props> {
     }
     }
   }
   }
 
 
+  onChangeDataSource = datasource => {
+    const { panel } = this.props;
+    const { currentDS } = this.state;
+
+    // switching to mixed
+    if (datasource.meta.mixed) {
+      panel.targets.forEach(target => {
+        target.datasource = panel.datasource;
+        if (!target.datasource) {
+          target.datasource = config.defaultDatasource;
+        }
+      });
+    } 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.refresh();
+
+    this.setState({
+      currentDS: datasource,
+    });
+  };
+
+  loadHelp = () => {
+    const { currentDS } = this.state;
+    const hasHelp = currentDS.meta.hasQueryHelp;
+
+    if (hasHelp) {
+      this.setState({
+        helpContent: <h3>Loading help...</h3>,
+        isLoadingHelp: true,
+      });
+
+      this.backendSrv
+        .get(`/api/plugins/${currentDS.meta.id}/markdown/query_help`)
+        .then(res => {
+          const md = new Remarkable();
+          const helpHtml = md.render(res);
+          this.setState({
+            helpContent: <div className="markdown-html" dangerouslySetInnerHTML={{ __html: helpHtml }} />,
+            isLoadingHelp: false,
+          });
+        })
+        .catch(() => {
+          this.setState({
+            helpContent: <h3>'Error occured when loading help'</h3>,
+            isLoadingHelp: false,
+          });
+        });
+    }
+  };
+
+  renderQueryInspector = () => {
+    const { panel } = this.props;
+    return <QueryInspector panel={panel} LoadingPlaceholder={LoadingPlaceholder} />;
+  };
+
+  renderHelp = () => {
+    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} onChange={this.onChangeDataSource} current={currentDS} />;
+  };
+
+  renderMixedPicker = () => {
+    return (
+      <DataSourcePicker
+        datasources={this.datasources}
+        onChange={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() {
-    return <div ref={element => (this.element = element)} className="panel-height-helper" />;
+    const { panel } = this.props;
+    const { currentDS, isAddingMixed } = this.state;
+    const { hasQueryHelp } = currentDS.meta;
+
+    const queryInspector = {
+      title: 'Query Inspector',
+      render: this.renderQueryInspector,
+    };
+
+    const dsHelp = {
+      heading: 'Help',
+      icon: 'fa fa-question',
+      disabled: !hasQueryHelp,
+      onClick: this.loadHelp,
+      render: this.renderHelp,
+    };
+
+    return (
+      <EditorTabBody heading="Queries" renderToolbar={this.renderToolbar} toolbarItems={[queryInspector, dsHelp]}>
+        <>
+          <PanelOptionSection>
+            <div className="query-editor-rows">
+              <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>
+                </div>
+                <div className="gf-form">
+                  {!isAddingMixed && (
+                    <button className="btn btn-secondary gf-form-btn" onClick={this.onAddQueryClick}>
+                      Add Query
+                    </button>
+                  )}
+                  {isAddingMixed && this.renderMixedPicker()}
+                </div>
+              </div>
+            </div>
+          </PanelOptionSection>
+          <PanelOptionSection>
+            <QueryOptions panel={panel} datasource={currentDS} />
+          </PanelOptionSection>
+        </>
+      </EditorTabBody>
+    );
   }
   }
 }
 }

+ 220 - 0
public/app/features/dashboard/dashgrid/QueryInspector.tsx

@@ -0,0 +1,220 @@
+import React, { PureComponent } from 'react';
+import { JSONFormatter } from 'app/core/components/JSONFormatter/JSONFormatter';
+import appEvents from 'app/core/app_events';
+import { CopyToClipboard } from 'app/core/components/CopyToClipboard/CopyToClipboard';
+
+interface DsQuery {
+  isLoading: boolean;
+  response: {};
+}
+
+interface Props {
+  panel: any;
+  LoadingPlaceholder: any;
+}
+
+interface State {
+  allNodesExpanded: boolean;
+  isMocking: boolean;
+  mockedResponse: string;
+  dsQuery: DsQuery;
+}
+
+export class QueryInspector extends PureComponent<Props, State> {
+  formattedJson: any;
+  clipboard: any;
+
+  constructor(props) {
+    super(props);
+    this.state = {
+      allNodesExpanded: null,
+      isMocking: false,
+      mockedResponse: '',
+      dsQuery: {
+        isLoading: false,
+        response: {},
+      },
+    };
+  }
+
+  componentDidMount() {
+    const { panel } = this.props;
+    panel.events.on('refresh', this.onPanelRefresh);
+    appEvents.on('ds-request-response', this.onDataSourceResponse);
+    panel.refresh();
+  }
+
+  componentWillUnmount() {
+    const { panel } = this.props;
+    appEvents.off('ds-request-response', this.onDataSourceResponse);
+    panel.events.off('refresh', this.onPanelRefresh);
+  }
+
+  handleMocking(response) {
+    const { mockedResponse } = this.state;
+    let mockedData;
+    try {
+      mockedData = JSON.parse(mockedResponse);
+    } catch (err) {
+      appEvents.emit('alert-error', ['R: Failed to parse mocked response']);
+      return;
+    }
+
+    response.data = mockedData;
+  }
+
+  onPanelRefresh = () => {
+    this.setState(prevState => ({
+      ...prevState,
+      dsQuery: {
+        isLoading: true,
+        response: {},
+      },
+    }));
+  };
+
+  onDataSourceResponse = (response: any = {}) => {
+    if (this.state.isMocking) {
+      this.handleMocking(response);
+      return;
+    }
+
+    response = { ...response }; // clone - dont modify the response
+
+    if (response.headers) {
+      delete response.headers;
+    }
+
+    if (response.config) {
+      response.request = response.config;
+      delete response.config;
+      delete response.request.transformRequest;
+      delete response.request.transformResponse;
+      delete response.request.paramSerializer;
+      delete response.request.jsonpCallbackParam;
+      delete response.request.headers;
+      delete response.request.requestId;
+      delete response.request.inspect;
+      delete response.request.retry;
+      delete response.request.timeout;
+    }
+
+    if (response.data) {
+      response.response = response.data;
+
+      delete response.data;
+      delete response.status;
+      delete response.statusText;
+      delete response.$$config;
+    }
+    this.setState(prevState => ({
+      ...prevState,
+      dsQuery: {
+        isLoading: false,
+        response: response,
+      },
+    }));
+  };
+
+  setFormattedJson = formattedJson => {
+    this.formattedJson = formattedJson;
+  };
+
+  getTextForClipboard = () => {
+    return JSON.stringify(this.formattedJson, null, 2);
+  };
+
+  onClipboardSuccess = () => {
+    appEvents.emit('alert-success', ['Content copied to clipboard']);
+  };
+
+  onToggleExpand = () => {
+    this.setState(prevState => ({
+      ...prevState,
+      allNodesExpanded: !this.state.allNodesExpanded,
+    }));
+  };
+
+  onToggleMocking = () => {
+    this.setState(prevState => ({
+      ...prevState,
+      isMocking: !this.state.isMocking,
+    }));
+  };
+
+  getNrOfOpenNodes = () => {
+    if (this.state.allNodesExpanded === null) {
+      return 3; // 3 is default, ie when state is null
+    } else if (this.state.allNodesExpanded) {
+      return 20;
+    }
+    return 1;
+  };
+
+  setMockedResponse = evt => {
+    const mockedResponse = evt.target.value;
+    this.setState(prevState => ({
+      ...prevState,
+      mockedResponse,
+    }));
+  };
+
+  renderExpandCollapse = () => {
+    const { allNodesExpanded } = this.state;
+
+    const collapse = (
+      <>
+        <i className="fa fa-minus-square-o" /> Collapse All
+      </>
+    );
+    const expand = (
+      <>
+        <i className="fa fa-plus-square-o" /> Expand All
+      </>
+    );
+    return allNodesExpanded ? collapse : expand;
+  };
+
+  render() {
+    const { response, isLoading } = this.state.dsQuery;
+    const { LoadingPlaceholder } = this.props;
+    const { isMocking } = this.state;
+    const openNodes = this.getNrOfOpenNodes();
+
+    if (isLoading) {
+      return <LoadingPlaceholder text="Loading query inspector..." />;
+    }
+
+    return (
+      <>
+        <div className="pull-right">
+          <button className="btn btn-transparent btn-p-x-0 m-r-1" onClick={this.onToggleExpand}>
+            {this.renderExpandCollapse()}
+          </button>
+          <CopyToClipboard
+            className="btn btn-transparent btn-p-x-0"
+            text={this.getTextForClipboard}
+            onSuccess={this.onClipboardSuccess}
+          >
+            <i className="fa fa-clipboard" /> Copy to Clipboard
+          </CopyToClipboard>
+        </div>
+
+        {!isMocking && <JSONFormatter json={response} open={openNodes} onDidRender={this.setFormattedJson} />}
+        {isMocking && (
+          <div className="query-troubleshooter__body">
+            <div className="gf-form p-l-1 gf-form--v-stretch">
+              <textarea
+                className="gf-form-input"
+                style={{ width: '95%' }}
+                rows={10}
+                onInput={this.setMockedResponse}
+                placeholder="JSON"
+              />
+            </div>
+          </div>
+        )}
+      </>
+    );
+  }
+}

+ 167 - 0
public/app/features/dashboard/dashgrid/QueryOptions.tsx

@@ -0,0 +1,167 @@
+// Libraries
+import React, { PureComponent } from 'react';
+
+// Utils
+import { isValidTimeSpan } from 'app/core/utils/rangeutil';
+
+// Components
+import { Switch } from 'app/core/components/Switch/Switch';
+import { Input } from 'app/core/components/Form';
+import { EventsWithValidation } from 'app/core/components/Form/Input';
+import { InputStatus } from 'app/core/components/Form/Input';
+import DataSourceOption from './DataSourceOption';
+
+// Types
+import { PanelModel } from '../panel_model';
+import { ValidationEvents, DataSourceSelectItem } from 'app/types';
+
+const timeRangeValidationEvents: ValidationEvents = {
+  [EventsWithValidation.onBlur]: [
+    {
+      rule: value => {
+        if (!value) {
+          return true;
+        }
+        return isValidTimeSpan(value);
+      },
+      errorMessage: 'Not a valid timespan',
+    },
+  ],
+};
+
+const emptyToNull = (value: string) => {
+  return value === '' ? null : value;
+};
+
+interface Props {
+  panel: PanelModel;
+  datasource: DataSourceSelectItem;
+}
+
+export class QueryOptions extends PureComponent<Props> {
+  onOverrideTime = (evt, status: InputStatus) => {
+    const { value } = evt.target;
+    const { panel } = this.props;
+    const emptyToNullValue = emptyToNull(value);
+    if (status === InputStatus.Valid && panel.timeFrom !== emptyToNullValue) {
+      panel.timeFrom = emptyToNullValue;
+      panel.refresh();
+    }
+  };
+
+  onTimeShift = (evt, status: InputStatus) => {
+    const { value } = evt.target;
+    const { panel } = this.props;
+    const emptyToNullValue = emptyToNull(value);
+    if (status === InputStatus.Valid && panel.timeShift !== emptyToNullValue) {
+      panel.timeShift = emptyToNullValue;
+      panel.refresh();
+    }
+  };
+
+  onToggleTimeOverride = () => {
+    const { panel } = this.props;
+    panel.hideTimeOverride = !panel.hideTimeOverride;
+    panel.refresh();
+  };
+
+  renderOptions() {
+    const { datasource, panel } = this.props;
+    const { queryOptions } = datasource.meta;
+
+    if (!queryOptions) {
+      return null;
+    }
+
+    const onChangeFn = (panelKey: string) => {
+      return (value: string | number) => {
+        panel[panelKey] = value;
+        panel.refresh();
+      };
+    };
+
+    const allOptions = {
+      cacheTimeout: {
+        label: 'Cache timeout',
+        placeholder: '60',
+        name: 'cacheTimeout',
+        value: panel.cacheTimeout,
+        tooltipInfo: (
+          <>
+            If your time series store has a query cache this option can override the default cache timeout. Specify a
+            numeric value in seconds.
+          </>
+        ),
+      },
+      maxDataPoints: {
+        label: 'Max data points',
+        placeholder: 'auto',
+        name: 'maxDataPoints',
+        value: panel.maxDataPoints,
+        tooltipInfo: (
+          <>
+            The maximum data points the query should return. For graphs this is automatically set to one data point per
+            pixel.
+          </>
+        ),
+      },
+      minInterval: {
+        label: 'Min time interval',
+        placeholder: '0',
+        name: 'minInterval',
+        value: panel.interval,
+        panelKey: 'interval',
+        tooltipInfo: (
+          <>
+            A lower limit for the auto group by time interval. Recommended to be set to write frequency, for example{' '}
+            <code>1m</code> if your data is written every minute. Access auto interval via variable{' '}
+            <code>$__interval</code> for time range string and <code>$__interval_ms</code> for numeric variable that can
+            be used in math expressions.
+          </>
+        ),
+      },
+    };
+
+    return Object.keys(queryOptions).map(key => {
+      const options = allOptions[key];
+      return <DataSourceOption key={key} {...options} onChange={onChangeFn(allOptions[key].panelKey || key)} />;
+    });
+  }
+
+  render = () => {
+    const hideTimeOverride = this.props.panel.hideTimeOverride;
+    return (
+      <div className="gf-form-inline">
+        {this.renderOptions()}
+
+        <div className="gf-form">
+          <span className="gf-form-label">Relative time</span>
+          <Input
+            type="text"
+            className="width-6"
+            placeholder="1h"
+            onBlur={this.onOverrideTime}
+            validationEvents={timeRangeValidationEvents}
+            hideErrorMessage={true}
+          />
+        </div>
+
+        <div className="gf-form">
+          <span className="gf-form-label">Time shift</span>
+          <Input
+            type="text"
+            className="width-6"
+            placeholder="1h"
+            onBlur={this.onTimeShift}
+            validationEvents={timeRangeValidationEvents}
+            hideErrorMessage={true}
+          />
+        </div>
+
+        <div className="gf-form-inline">
+          <Switch label="Hide time info" checked={hideTimeOverride} onChange={this.onToggleTimeOverride} />
+        </div>
+      </div>
+    );
+  };
+}

+ 221 - 0
public/app/features/dashboard/dashgrid/VisualizationTab.tsx

@@ -0,0 +1,221 @@
+// Libraries
+import React, { PureComponent } from 'react';
+
+// Utils & Services
+import { getAngularLoader, AngularComponent } from 'app/core/services/AngularLoader';
+
+// Components
+import { EditorTabBody } from './EditorTabBody';
+import { VizTypePicker } from './VizTypePicker';
+import { FadeIn } from 'app/core/components/Animations/FadeIn';
+import { PanelOptionSection } from './PanelOptionSection';
+
+// Types
+import { PanelModel } from '../panel_model';
+import { DashboardModel } from '../dashboard_model';
+import { PanelPlugin } from 'app/types/plugins';
+
+interface Props {
+  panel: PanelModel;
+  dashboard: DashboardModel;
+  plugin: PanelPlugin;
+  angularPanel?: AngularComponent;
+  onTypeChanged: (newType: PanelPlugin) => void;
+}
+
+interface State {
+  isVizPickerOpen: boolean;
+  searchQuery: string;
+}
+
+export class VisualizationTab extends PureComponent<Props, State> {
+  element: HTMLElement;
+  angularOptions: AngularComponent;
+  searchInput: HTMLElement;
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      isVizPickerOpen: false,
+      searchQuery: '',
+    };
+  }
+
+  getPanelDefaultOptions = () => {
+    const { panel, plugin } = this.props;
+
+    if (plugin.exports.PanelDefaults) {
+      return panel.getOptions(plugin.exports.PanelDefaults.options);
+    }
+
+    return panel.getOptions(plugin.exports.PanelDefaults);
+  };
+
+  renderPanelOptions() {
+    const { plugin, angularPanel } = this.props;
+    const { PanelOptions } = plugin.exports;
+
+    if (angularPanel) {
+      return <div ref={element => (this.element = element)} />;
+    }
+
+    return (
+      <PanelOptionSection>
+        {PanelOptions ? (
+          <PanelOptions options={this.getPanelDefaultOptions()} onChange={this.onPanelOptionsChanged} />
+        ) : (
+          <p>Visualization has no options</p>
+        )}
+      </PanelOptionSection>
+    );
+  }
+
+  componentDidMount() {
+    if (this.shouldLoadAngularOptions()) {
+      this.loadAngularOptions();
+    }
+  }
+
+  componentDidUpdate(prevProps: Props) {
+    if (this.props.plugin !== prevProps.plugin) {
+      this.cleanUpAngularOptions();
+    }
+
+    if (this.shouldLoadAngularOptions()) {
+      this.loadAngularOptions();
+    }
+  }
+
+  shouldLoadAngularOptions() {
+    return this.props.angularPanel && this.element && !this.angularOptions;
+  }
+
+  loadAngularOptions() {
+    const { angularPanel } = this.props;
+
+    const scope = angularPanel.getScope();
+
+    // When full page reloading in edit mode the angular panel has on fully compiled & instantiated yet
+    if (!scope.$$childHead) {
+      setTimeout(() => {
+        this.forceUpdate();
+      });
+      return;
+    }
+
+    const panelCtrl = scope.$$childHead.ctrl;
+
+    let template = '';
+    for (let i = 0; i < panelCtrl.editorTabs.length; i++) {
+      template +=
+        `
+      <div class="panel-option-section" ng-cloak>` +
+        (i > 0 ? `<div class="panel-option-section__header">{{ctrl.editorTabs[${i}].title}}</div>` : '') +
+        `<div class="panel-option-section__body">
+          <panel-editor-tab editor-tab="ctrl.editorTabs[${i}]" ctrl="ctrl"></panel-editor-tab>
+        </div>
+      </div>
+      `;
+    }
+
+    const loader = getAngularLoader();
+    const scopeProps = { ctrl: panelCtrl };
+
+    this.angularOptions = loader.load(this.element, scopeProps, template);
+  }
+
+  componentWillUnmount() {
+    this.cleanUpAngularOptions();
+  }
+
+  cleanUpAngularOptions() {
+    if (this.angularOptions) {
+      this.angularOptions.destroy();
+      this.angularOptions = null;
+    }
+  }
+
+  onPanelOptionsChanged = (options: any) => {
+    this.props.panel.updateOptions(options);
+    this.forceUpdate();
+  };
+
+  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 { 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>
+          <button className="btn btn-link toolbar__close" 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>
+      );
+    }
+  };
+
+  onTypeChanged = (plugin: PanelPlugin) => {
+    if (plugin.id === this.props.plugin.id) {
+      this.setState({ isVizPickerOpen: false });
+    } else {
+      this.props.onTypeChanged(plugin);
+    }
+  };
+
+  render() {
+    const { plugin } = this.props;
+    const { isVizPickerOpen, searchQuery } = this.state;
+
+    return (
+      <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>
+    );
+  }
+}

+ 40 - 34
public/app/features/dashboard/dashgrid/VizTypePicker.tsx

@@ -1,29 +1,31 @@
 import React, { PureComponent } from 'react';
 import React, { PureComponent } from 'react';
-import classNames from 'classnames';
+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 CustomScrollbar from 'app/core/components/CustomScrollbar/CustomScrollbar';
-import _ from 'lodash';
+import VizTypePickerPlugin from './VizTypePickerPlugin';
 
 
-interface Props {
-  currentType: string;
+export interface Props {
+  current: PanelPlugin;
   onTypeChanged: (newType: PanelPlugin) => void;
   onTypeChanged: (newType: PanelPlugin) => void;
+  searchQuery: string;
+  onClose: () => void;
 }
 }
 
 
-interface State {
-  pluginList: PanelPlugin[];
-}
+export class VizTypePicker extends PureComponent<Props> {
+  searchInput: HTMLElement;
+  pluginList = this.getPanelPlugins('');
 
 
-export class VizTypePicker extends PureComponent<Props, State> {
   constructor(props) {
   constructor(props) {
     super(props);
     super(props);
+  }
 
 
-    this.state = {
-      pluginList: this.getPanelPlugins(''),
-    };
+  get maxSelectedIndex() {
+    const filteredPluginList = this.getFilteredPluginList();
+    return filteredPluginList.length - 1;
   }
   }
 
 
-  getPanelPlugins(filter) {
+  getPanelPlugins(filter): PanelPlugin[] {
     const panels = _.chain(config.panels)
     const panels = _.chain(config.panels)
       .filter({ hideFromList: false })
       .filter({ hideFromList: false })
       .map(item => item)
       .map(item => item)
@@ -33,35 +35,39 @@ export class VizTypePicker extends PureComponent<Props, State> {
     return _.sortBy(panels, 'sort');
     return _.sortBy(panels, 'sort');
   }
   }
 
 
-  renderVizPlugin = (plugin, index) => {
-    const cssClass = classNames({
-      'viz-picker__item': true,
-      'viz-picker__item--selected': plugin.id === this.props.currentType,
-    });
+  renderVizPlugin = (plugin: PanelPlugin, index: number) => {
+    const { onTypeChanged } = this.props;
+    const isCurrent = plugin.id === this.props.current.id;
 
 
     return (
     return (
-      <div key={index} className={cssClass} onClick={() => this.props.onTypeChanged(plugin)} title={plugin.name}>
-        <img className="viz-picker__item-img" src={plugin.info.logos.small} />
-        <div className="viz-picker__item-name">{plugin.name}</div>
-      </div>
+      <VizTypePickerPlugin
+        key={plugin.id}
+        isCurrent={isCurrent}
+        plugin={plugin}
+        onClick={() => onTypeChanged(plugin)}
+      />
     );
     );
   };
   };
 
 
+  getFilteredPluginList = (): PanelPlugin[] => {
+    const { searchQuery } = this.props;
+    const regex = new RegExp(searchQuery, 'i');
+    const pluginList = this.pluginList;
+
+    const filtered = pluginList.filter(item => {
+      return regex.test(item.name);
+    });
+
+    return filtered;
+  };
+
   render() {
   render() {
+    const filteredPluginList = this.getFilteredPluginList();
+
     return (
     return (
       <div className="viz-picker">
       <div className="viz-picker">
-        <div className="viz-picker__search">
-          <div className="gf-form gf-form--grow">
-            <label className="gf-form--has-input-icon gf-form--grow">
-              <input type="text" className="gf-form-input" placeholder="Search type" />
-              <i className="gf-form-input-icon fa fa-search" />
-            </label>
-          </div>
-        </div>
-        <div className="viz-picker__items">
-          <CustomScrollbar>
-            <div className="scroll-margin-helper">{this.state.pluginList.map(this.renderVizPlugin)}</div>
-          </CustomScrollbar>
+        <div className="viz-picker-list">
+          {filteredPluginList.map((plugin, index) => this.renderVizPlugin(plugin, index))}
         </div>
         </div>
       </div>
       </div>
     );
     );

部分文件因为文件数量过多而无法显示