Kaynağa Gözat

Merge pull request #14930 from grafana/react-query-editor

React query editor (part1)
Torkel Ödegaard 7 yıl önce
ebeveyn
işleme
dfe1b20f3d

+ 33 - 0
packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.tsx

@@ -1,4 +1,5 @@
 import React, { PureComponent } from 'react';
 import React, { PureComponent } from 'react';
+import _ from 'lodash';
 import Scrollbars from 'react-custom-scrollbars';
 import Scrollbars from 'react-custom-scrollbars';
 
 
 interface Props {
 interface Props {
@@ -8,6 +9,8 @@ interface Props {
   autoHideDuration?: number;
   autoHideDuration?: number;
   autoMaxHeight?: string;
   autoMaxHeight?: string;
   hideTracksWhenNotNeeded?: boolean;
   hideTracksWhenNotNeeded?: boolean;
+  scrollTop?: number;
+  setScrollTop: (value: React.MouseEvent<HTMLElement>) => void;
   autoHeightMin?: number | string;
   autoHeightMin?: number | string;
 }
 }
 
 
@@ -22,14 +25,44 @@ export class CustomScrollbar extends PureComponent<Props> {
     autoHideDuration: 200,
     autoHideDuration: 200,
     autoMaxHeight: '100%',
     autoMaxHeight: '100%',
     hideTracksWhenNotNeeded: false,
     hideTracksWhenNotNeeded: false,
+    scrollTop: 0,
+    setScrollTop: () => {},
     autoHeightMin: '0'
     autoHeightMin: '0'
   };
   };
 
 
+  private ref: React.RefObject<Scrollbars>;
+
+  constructor(props: Props) {
+    super(props);
+    this.ref = React.createRef<Scrollbars>();
+  }
+
+  updateScroll() {
+    const ref = this.ref.current;
+
+    if (ref && !_.isNil(this.props.scrollTop)) {
+      if (this.props.scrollTop > 10000) {
+        ref.scrollToBottom();
+      } else {
+        ref.scrollTop(this.props.scrollTop);
+      }
+   }
+  }
+
+  componentDidMount() {
+    this.updateScroll();
+  }
+
+  componentDidUpdate() {
+    this.updateScroll();
+  }
+
   render() {
   render() {
     const { customClassName, children, autoMaxHeight } = this.props;
     const { customClassName, children, autoMaxHeight } = this.props;
 
 
     return (
     return (
       <Scrollbars
       <Scrollbars
+        ref={this.ref}
         className={customClassName}
         className={customClassName}
         autoHeight={true}
         autoHeight={true}
         // These autoHeightMin & autoHeightMax options affect firefox and chrome differently.
         // These autoHeightMin & autoHeightMax options affect firefox and chrome differently.

+ 4 - 2
public/app/features/dashboard/panel_editor/EditorTabBody.tsx

@@ -10,6 +10,8 @@ interface Props {
   heading: string;
   heading: string;
   renderToolbar?: () => JSX.Element;
   renderToolbar?: () => JSX.Element;
   toolbarItems?: EditorToolbarView[];
   toolbarItems?: EditorToolbarView[];
+  scrollTop?: number;
+  setScrollTop?: (value: React.MouseEvent<HTMLElement>) => void;
 }
 }
 
 
 export interface EditorToolbarView {
 export interface EditorToolbarView {
@@ -103,7 +105,7 @@ export class EditorTabBody extends PureComponent<Props, State> {
   }
   }
 
 
   render() {
   render() {
-    const { children, renderToolbar, heading, toolbarItems } = this.props;
+    const { children, renderToolbar, heading, toolbarItems, scrollTop, setScrollTop } = this.props;
     const { openView, fadeIn, isOpen } = this.state;
     const { openView, fadeIn, isOpen } = this.state;
 
 
     return (
     return (
@@ -119,7 +121,7 @@ export class EditorTabBody extends PureComponent<Props, State> {
           )}
           )}
         </div>
         </div>
         <div className="panel-editor__scroll">
         <div className="panel-editor__scroll">
-          <CustomScrollbar autoHide={false}>
+          <CustomScrollbar autoHide={false} scrollTop={scrollTop} setScrollTop={setScrollTop}>
             <div className="panel-editor__content">
             <div className="panel-editor__content">
               <FadeIn in={isOpen} duration={200} unmountOnExit={true}>
               <FadeIn in={isOpen} duration={200} unmountOnExit={true}>
                 {openView && this.renderOpenView(openView)}
                 {openView && this.renderOpenView(openView)}

+ 54 - 85
public/app/features/dashboard/panel_editor/QueriesTab.tsx

@@ -3,18 +3,16 @@ import React, { PureComponent } from 'react';
 import _ from 'lodash';
 import _ from 'lodash';
 
 
 // Components
 // Components
-import 'app/features/panel/metrics_tab';
 import { EditorTabBody, EditorToolbarView } from './EditorTabBody';
 import { EditorTabBody, EditorToolbarView } from './EditorTabBody';
 import { DataSourcePicker } from 'app/core/components/Select/DataSourcePicker';
 import { DataSourcePicker } from 'app/core/components/Select/DataSourcePicker';
 import { QueryInspector } from './QueryInspector';
 import { QueryInspector } from './QueryInspector';
 import { QueryOptions } from './QueryOptions';
 import { QueryOptions } from './QueryOptions';
-import { AngularQueryComponentScope } from 'app/features/panel/metrics_tab';
 import { PanelOptionsGroup } from '@grafana/ui';
 import { PanelOptionsGroup } from '@grafana/ui';
+import { QueryEditorRow } from './QueryEditorRow';
 
 
 // Services
 // Services
 import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
 import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
 import { BackendSrv, getBackendSrv } from 'app/core/services/backend_srv';
 import { BackendSrv, getBackendSrv } from 'app/core/services/backend_srv';
-import { AngularComponent, getAngularLoader } from 'app/core/services/AngularLoader';
 import config from 'app/core/config';
 import config from 'app/core/config';
 
 
 // Types
 // Types
@@ -34,66 +32,27 @@ interface State {
   isLoadingHelp: boolean;
   isLoadingHelp: boolean;
   isPickerOpen: boolean;
   isPickerOpen: boolean;
   isAddingMixed: boolean;
   isAddingMixed: boolean;
+  scrollTop: number;
 }
 }
 
 
 export class QueriesTab extends PureComponent<Props, State> {
 export class QueriesTab extends PureComponent<Props, State> {
-  element: HTMLElement;
-  component: AngularComponent;
   datasources: DataSourceSelectItem[] = getDatasourceSrv().getMetricSources();
   datasources: DataSourceSelectItem[] = getDatasourceSrv().getMetricSources();
   backendSrv: BackendSrv = getBackendSrv();
   backendSrv: BackendSrv = getBackendSrv();
 
 
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      isLoadingHelp: false,
-      currentDS: this.findCurrentDataSource(),
-      helpContent: null,
-      isPickerOpen: false,
-      isAddingMixed: false,
-    };
-  }
+  state: State = {
+    isLoadingHelp: false,
+    currentDS: this.findCurrentDataSource(),
+    helpContent: null,
+    isPickerOpen: false,
+    isAddingMixed: false,
+    scrollTop: 0,
+  };
 
 
   findCurrentDataSource(): DataSourceSelectItem {
   findCurrentDataSource(): DataSourceSelectItem {
     const { panel } = this.props;
     const { panel } = this.props;
     return this.datasources.find(datasource => datasource.value === panel.datasource) || this.datasources[0];
     return this.datasources.find(datasource => datasource.value === panel.datasource) || this.datasources[0];
   }
   }
 
 
-  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() {
-    if (!this.element) {
-      return;
-    }
-
-    const loader = getAngularLoader();
-    const template = '<metrics-tab />';
-    const scopeProps = {
-      ctrl: this.getAngularQueryComponentScope(),
-    };
-
-    this.component = loader.load(this.element, scopeProps, template);
-  }
-
-  componentWillUnmount() {
-    if (this.component) {
-      this.component.destroy();
-    }
-  }
-
   onChangeDataSource = datasource => {
   onChangeDataSource = datasource => {
     const { panel } = this.props;
     const { panel } = this.props;
     const { currentDS } = this.state;
     const { currentDS } = this.state;
@@ -137,7 +96,7 @@ export class QueriesTab extends PureComponent<Props, State> {
 
 
   onAddQuery = (query?: Partial<DataQuery>) => {
   onAddQuery = (query?: Partial<DataQuery>) => {
     this.props.panel.addQuery(query);
     this.props.panel.addQuery(query);
-    this.forceUpdate();
+    this.setState({ scrollTop: this.state.scrollTop + 100000 });
   };
   };
 
 
   onAddQueryClick = () => {
   onAddQueryClick = () => {
@@ -146,9 +105,7 @@ export class QueriesTab extends PureComponent<Props, State> {
       return;
       return;
     }
     }
 
 
-    this.props.panel.addQuery();
-    this.component.digest();
-    this.forceUpdate();
+    this.onAddQuery();
   };
   };
 
 
   onRemoveQuery = (query: DataQuery) => {
   onRemoveQuery = (query: DataQuery) => {
@@ -171,9 +128,21 @@ export class QueriesTab extends PureComponent<Props, State> {
   };
   };
 
 
   renderToolbar = () => {
   renderToolbar = () => {
-    const { currentDS } = this.state;
+    const { currentDS, isAddingMixed } = this.state;
 
 
-    return <DataSourcePicker datasources={this.datasources} onChange={this.onChangeDataSource} current={currentDS} />;
+    return (
+      <>
+        <DataSourcePicker datasources={this.datasources} onChange={this.onChangeDataSource} current={currentDS} />
+        <div className="m-l-2">
+          {!isAddingMixed && (
+            <button className="btn navbar-button navbar-button--primary" onClick={this.onAddQueryClick}>
+              Add Query
+            </button>
+          )}
+          {isAddingMixed && this.renderMixedPicker()}
+        </div>
+      </>
+    );
   };
   };
 
 
   renderMixedPicker = () => {
   renderMixedPicker = () => {
@@ -190,17 +159,21 @@ export class QueriesTab extends PureComponent<Props, State> {
 
 
   onAddMixedQuery = datasource => {
   onAddMixedQuery = datasource => {
     this.onAddQuery({ datasource: datasource.name });
     this.onAddQuery({ datasource: datasource.name });
-    this.component.digest();
-    this.setState({ isAddingMixed: false });
+    this.setState({ isAddingMixed: false, scrollTop: this.state.scrollTop + 10000 });
   };
   };
 
 
   onMixedPickerBlur = () => {
   onMixedPickerBlur = () => {
     this.setState({ isAddingMixed: false });
     this.setState({ isAddingMixed: false });
   };
   };
 
 
+  setScrollTop = (event: React.MouseEvent<HTMLElement>) => {
+    const target = event.target as HTMLElement;
+    this.setState({ scrollTop: target.scrollTop });
+  };
+
   render() {
   render() {
     const { panel } = this.props;
     const { panel } = this.props;
-    const { currentDS, isAddingMixed } = this.state;
+    const { currentDS, scrollTop } = this.state;
 
 
     const queryInspector: EditorToolbarView = {
     const queryInspector: EditorToolbarView = {
       title: 'Query Inspector',
       title: 'Query Inspector',
@@ -214,32 +187,28 @@ export class QueriesTab extends PureComponent<Props, State> {
     };
     };
 
 
     return (
     return (
-      <EditorTabBody heading="Queries" renderToolbar={this.renderToolbar} toolbarItems={[queryInspector, dsHelp]}>
+      <EditorTabBody
+        heading="Queries to"
+        renderToolbar={this.renderToolbar}
+        toolbarItems={[queryInspector, dsHelp]}
+        setScrollTop={this.setScrollTop}
+        scrollTop={scrollTop}
+      >
         <>
         <>
-          <PanelOptionsGroup>
-            <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>
-          </PanelOptionsGroup>
+          <div className="query-editor-rows">
+            {panel.targets.map((query, index) => (
+              <QueryEditorRow
+                datasourceName={query.datasource || panel.datasource}
+                key={query.refId}
+                panel={panel}
+                query={query}
+                onRemoveQuery={this.onRemoveQuery}
+                onAddQuery={this.onAddQuery}
+                onMoveQuery={this.onMoveQuery}
+                inMixedMode={currentDS.meta.mixed}
+              />
+            ))}
+          </div>
           <PanelOptionsGroup>
           <PanelOptionsGroup>
             <QueryOptions panel={panel} datasource={currentDS} />
             <QueryOptions panel={panel} datasource={currentDS} />
           </PanelOptionsGroup>
           </PanelOptionsGroup>

+ 237 - 0
public/app/features/dashboard/panel_editor/QueryEditorRow.tsx

@@ -0,0 +1,237 @@
+// Libraries
+import React, { PureComponent } from 'react';
+import classNames from 'classnames';
+import _ from 'lodash';
+
+// Utils & Services
+import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
+import { AngularComponent, getAngularLoader } from 'app/core/services/AngularLoader';
+import { Emitter } from 'app/core/utils/emitter';
+
+// Types
+import { PanelModel } from '../panel_model';
+import { DataQuery, DataSourceApi } from 'app/types/series';
+
+interface Props {
+  panel: PanelModel;
+  query: DataQuery;
+  onAddQuery: (query?: DataQuery) => void;
+  onRemoveQuery: (query: DataQuery) => void;
+  onMoveQuery: (query: DataQuery, direction: number) => void;
+  datasourceName: string | null;
+  inMixedMode: boolean;
+}
+
+interface State {
+  datasource: DataSourceApi | null;
+  isCollapsed: boolean;
+  angularScope: AngularQueryComponentScope | null;
+}
+
+export class QueryEditorRow extends PureComponent<Props, State> {
+  element: HTMLElement | null = null;
+  angularQueryEditor: AngularComponent | null = null;
+
+  state: State = {
+    datasource: null,
+    isCollapsed: false,
+    angularScope: null,
+  };
+
+  componentDidMount() {
+    this.loadDatasource();
+  }
+
+  getAngularQueryComponentScope(): AngularQueryComponentScope {
+    const { panel, query } = this.props;
+    const { datasource } = this.state;
+
+    return {
+      datasource: datasource,
+      target: query,
+      panel: panel,
+      refresh: () => panel.refresh(),
+      render: () => panel.render,
+      events: panel.events,
+    };
+  }
+
+  async loadDatasource() {
+    const { query, panel } = this.props;
+    const dataSourceSrv = getDatasourceSrv();
+    const datasource = await dataSourceSrv.get(query.datasource || panel.datasource);
+
+    this.setState({ datasource });
+  }
+
+  componentDidUpdate() {
+    const { datasource } = this.state;
+
+    // check if we need to load another datasource
+    if (datasource && datasource.name !== this.props.datasourceName) {
+      if (this.angularQueryEditor) {
+        this.angularQueryEditor.destroy();
+        this.angularQueryEditor = null;
+      }
+      this.loadDatasource();
+      return;
+    }
+
+    if (!this.element || this.angularQueryEditor) {
+      return;
+    }
+
+    const loader = getAngularLoader();
+    const template = '<plugin-component type="query-ctrl" />';
+    const scopeProps = { ctrl: this.getAngularQueryComponentScope() };
+
+    this.angularQueryEditor = loader.load(this.element, scopeProps, template);
+
+    // give angular time to compile
+    setTimeout(() => {
+      this.setState({ angularScope: scopeProps.ctrl });
+    }, 10);
+  }
+
+  componentWillUnmount() {
+    if (this.angularQueryEditor) {
+      this.angularQueryEditor.destroy();
+    }
+  }
+
+  onToggleCollapse = () => {
+    this.setState({ isCollapsed: !this.state.isCollapsed });
+  };
+
+  renderPluginEditor() {
+    const { datasource } = this.state;
+
+    if (datasource.pluginExports.QueryCtrl) {
+      return <div ref={element => (this.element = element)} />;
+    }
+
+    if (datasource.pluginExports.QueryEditor) {
+      const QueryEditor = datasource.pluginExports.QueryEditor;
+      return <QueryEditor />;
+    }
+
+    return <div>Data source plugin does not export any Query Editor component</div>;
+  }
+
+  onToggleEditMode = () => {
+    const { angularScope } = this.state;
+
+    if (angularScope && angularScope.toggleEditorMode) {
+      angularScope.toggleEditorMode();
+      this.angularQueryEditor.digest();
+    }
+
+    if (this.state.isCollapsed) {
+      this.setState({ isCollapsed: false });
+    }
+  };
+
+  get hasTextEditMode() {
+    const { angularScope } = this.state;
+    return angularScope && angularScope.toggleEditorMode;
+  }
+
+  onRemoveQuery = () => {
+    this.props.onRemoveQuery(this.props.query);
+  };
+
+  onCopyQuery = () => {
+    const copy = _.cloneDeep(this.props.query);
+    this.props.onAddQuery(copy);
+  };
+
+  onDisableQuery = () => {
+    this.props.query.hide = !this.props.query.hide;
+    this.forceUpdate();
+  };
+
+  renderCollapsedText(): string | null {
+    const { angularScope } = this.state;
+
+    if (angularScope && angularScope.getCollapsedText) {
+      return angularScope.getCollapsedText();
+    }
+
+    return null;
+  }
+
+  render() {
+    const { query, datasourceName, inMixedMode } = this.props;
+    const { datasource, isCollapsed } = this.state;
+    const isDisabled = query.hide;
+
+    const bodyClasses = classNames('query-editor-row__body gf-form-query', {
+      'query-editor-row__body--collapsed': isCollapsed,
+    });
+
+    const rowClasses = classNames('query-editor-row', {
+      'query-editor-row--disabled': isDisabled,
+      'gf-form-disabled': isDisabled,
+    });
+
+    if (!datasource) {
+      return null;
+    }
+
+    return (
+      <div className={rowClasses}>
+        <div className="query-editor-row__header">
+          <div className="query-editor-row__ref-id" onClick={this.onToggleCollapse}>
+            {isCollapsed && <i className="fa fa-caret-right" />}
+            {!isCollapsed && <i className="fa fa-caret-down" />}
+            <span>{query.refId}</span>
+            {inMixedMode && <em className="query-editor-row__context-info"> ({datasourceName})</em>}
+            {isDisabled && <em className="query-editor-row__context-info"> Disabled</em>}
+          </div>
+          <div className="query-editor-row__collapsed-text">
+            {isCollapsed && <div>{this.renderCollapsedText()}</div>}
+          </div>
+          <div className="query-editor-row__actions">
+            {this.hasTextEditMode && (
+              <button
+                className="query-editor-row__action"
+                onClick={this.onToggleEditMode}
+                title="Toggle text edit mode"
+              >
+                <i className="fa fa-fw fa-pencil" />
+              </button>
+            )}
+            <button className="query-editor-row__action" onClick={() => this.props.onMoveQuery(query, 1)}>
+              <i className="fa fa-fw fa-arrow-down" />
+            </button>
+            <button className="query-editor-row__action" onClick={() => this.props.onMoveQuery(query, -1)}>
+              <i className="fa fa-fw fa-arrow-up" />
+            </button>
+            <button className="query-editor-row__action" onClick={this.onCopyQuery} title="Duplicate query">
+              <i className="fa fa-fw fa-copy" />
+            </button>
+            <button className="query-editor-row__action" onClick={this.onDisableQuery} title="Disable/enable query">
+              {isDisabled && <i className="fa fa-fw fa-eye-slash" />}
+              {!isDisabled && <i className="fa fa-fw fa-eye" />}
+            </button>
+            <button className="query-editor-row__action" onClick={this.onRemoveQuery} title="Remove query">
+              <i className="fa fa-fw fa-trash" />
+            </button>
+          </div>
+        </div>
+        <div className={bodyClasses}>{this.renderPluginEditor()}</div>
+      </div>
+    );
+  }
+}
+
+export interface AngularQueryComponentScope {
+  target: DataQuery;
+  panel: PanelModel;
+  events: Emitter;
+  refresh: () => void;
+  render: () => void;
+  datasource: DataSourceApi;
+  toggleEditorMode?: () => void;
+  getCollapsedText?: () => string;
+}

+ 11 - 3
public/app/features/dashboard/panel_editor/VisualizationTab.tsx

@@ -26,6 +26,7 @@ interface Props {
 interface State {
 interface State {
   isVizPickerOpen: boolean;
   isVizPickerOpen: boolean;
   searchQuery: string;
   searchQuery: string;
+  scrollTop: number;
 }
 }
 
 
 export class VisualizationTab extends PureComponent<Props, State> {
 export class VisualizationTab extends PureComponent<Props, State> {
@@ -39,6 +40,7 @@ export class VisualizationTab extends PureComponent<Props, State> {
     this.state = {
     this.state = {
       isVizPickerOpen: false,
       isVizPickerOpen: false,
       searchQuery: '',
       searchQuery: '',
+      scrollTop: 0,
     };
     };
   }
   }
 
 
@@ -143,7 +145,7 @@ export class VisualizationTab extends PureComponent<Props, State> {
   };
   };
 
 
   onOpenVizPicker = () => {
   onOpenVizPicker = () => {
-    this.setState({ isVizPickerOpen: true });
+    this.setState({ isVizPickerOpen: true, scrollTop: 0 });
   };
   };
 
 
   onCloseVizPicker = () => {
   onCloseVizPicker = () => {
@@ -201,9 +203,14 @@ export class VisualizationTab extends PureComponent<Props, State> {
 
 
   renderHelp = () => <PluginHelp plugin={this.props.plugin} type="help" />;
   renderHelp = () => <PluginHelp plugin={this.props.plugin} type="help" />;
 
 
+  setScrollTop = (event: React.MouseEvent<HTMLElement>) => {
+    const target = event.target as HTMLElement;
+    this.setState({ scrollTop: target.scrollTop });
+  };
+
   render() {
   render() {
     const { plugin } = this.props;
     const { plugin } = this.props;
-    const { isVizPickerOpen, searchQuery } = this.state;
+    const { isVizPickerOpen, searchQuery, scrollTop } = this.state;
 
 
     const pluginHelp: EditorToolbarView = {
     const pluginHelp: EditorToolbarView = {
       heading: 'Help',
       heading: 'Help',
@@ -212,7 +219,8 @@ export class VisualizationTab extends PureComponent<Props, State> {
     };
     };
 
 
     return (
     return (
-      <EditorTabBody heading="Visualization" renderToolbar={this.renderToolbar} toolbarItems={[pluginHelp]}>
+      <EditorTabBody heading="Visualization" renderToolbar={this.renderToolbar} toolbarItems={[pluginHelp]}
+        scrollTop={scrollTop} setScrollTop={this.setScrollTop}>
         <>
         <>
           <FadeIn in={isVizPickerOpen} duration={200} unmountOnExit={true}>
           <FadeIn in={isVizPickerOpen} duration={200} unmountOnExit={true}>
             <VizTypePicker
             <VizTypePicker

+ 0 - 31
public/app/features/panel/metrics_tab.ts

@@ -1,31 +0,0 @@
-// Services & utils
-import coreModule from 'app/core/core_module';
-import { Emitter } from 'app/core/utils/emitter';
-
-// Types
-import { DashboardModel } from '../dashboard/dashboard_model';
-import { PanelModel } from '../dashboard/panel_model';
-import { DataQuery } from 'app/types';
-
-export interface AngularQueryComponentScope {
-  panel: PanelModel;
-  dashboard: DashboardModel;
-  events: Emitter;
-  refresh: () => void;
-  render: () => void;
-  removeQuery: (query: DataQuery) => void;
-  addQuery: (query?: DataQuery) => void;
-  moveQuery: (query: DataQuery, direction: number) => void;
-}
-
-/** @ngInject */
-export function metricsTabDirective() {
-  'use strict';
-  return {
-    restrict: 'E',
-    scope: true,
-    templateUrl: 'public/app/features/panel/partials/metrics_tab.html',
-  };
-}
-
-coreModule.directive('metricsTab', metricsTabDirective);

+ 0 - 24
public/app/features/panel/partials/metrics_tab.html

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

+ 1 - 43
public/app/features/panel/partials/query_editor_row.html

@@ -1,44 +1,2 @@
-<div class="gf-form-query">
-  <div ng-if="!ctrl.hideEditorRowActions" class="gf-form gf-form-query-letter-cell">
-    <label class="gf-form-label">
-      <a class="pointer" tabindex="1" ng-click="ctrl.toggleCollapse()">
-        <span ng-class="{muted: !ctrl.canCollapse}" class="gf-form-query-letter-cell-carret">
-          <i class="fa fa-caret-down" ng-hide="ctrl.collapsed"></i>
-          <i class="fa fa-caret-right" ng-show="ctrl.collapsed"></i>
-        </span>
-        <span class="gf-form-query-letter-cell-letter">{{ ctrl.target.refId }}</span>
-        <em class="gf-form-query-letter-cell-ds" ng-show="ctrl.target.datasource">({{ ctrl.target.datasource }})</em>
-      </a>
-    </label>
-  </div>
+<div ng-transclude class="gf-form-query-content"></div>
 
 
-  <div class="gf-form-query-content gf-form-query-content--collapsed" ng-if="ctrl.collapsed">
-    <div class="gf-form">
-      <label class="gf-form-label pointer gf-form-label--grow" ng-click="ctrl.toggleCollapse()">
-        {{ ctrl.collapsedText }}
-      </label>
-    </div>
-  </div>
-
-  <div ng-transclude class="gf-form-query-content" ng-if="!ctrl.collapsed"></div>
-
-  <div ng-if="!ctrl.hideEditorRowActions" class="gf-form">
-    <label class="gf-form-label dropdown">
-      <a class="pointer dropdown-toggle" data-toggle="dropdown" tabindex="1"> <i class="fa fa-bars"></i> </a>
-      <ul class="dropdown-menu pull-right" role="menu">
-        <li role="menuitem" ng-if="ctrl.hasTextEditMode">
-          <a tabindex="1" ng-click="ctrl.toggleEditorMode()">Toggle Edit Mode</a>
-        </li>
-        <li role="menuitem"><a tabindex="1" ng-click="ctrl.duplicateQuery()">Duplicate</a></li>
-        <li role="menuitem"><a tabindex="1" ng-click="ctrl.moveQuery(-1)">Move up</a></li>
-        <li role="menuitem"><a tabindex="1" ng-click="ctrl.moveQuery(1)">Move down</a></li>
-      </ul>
-    </label>
-    <label class="gf-form-label">
-      <a ng-click="ctrl.toggleHideQuery()" role="menuitem"> <i class="fa fa-eye"></i> </a>
-    </label>
-    <label class="gf-form-label">
-      <a class="pointer" tabindex="1" ng-click="ctrl.removeQuery(ctrl.target)"> <i class="fa fa-trash"></i> </a>
-    </label>
-  </div>
-</div>

+ 7 - 70
public/app/features/panel/query_editor_row.ts

@@ -3,89 +3,26 @@ import angular from 'angular';
 const module = angular.module('grafana.directives');
 const module = angular.module('grafana.directives');
 
 
 export class QueryRowCtrl {
 export class QueryRowCtrl {
-  collapsedText: string;
-  canCollapse: boolean;
-  getCollapsedText: any;
   target: any;
   target: any;
   queryCtrl: any;
   queryCtrl: any;
   panelCtrl: any;
   panelCtrl: any;
   panel: any;
   panel: any;
-  collapsed: any;
-  hideEditorRowActions: boolean;
+  hasTextEditMode: boolean;
 
 
   constructor() {
   constructor() {
     this.panelCtrl = this.queryCtrl.panelCtrl;
     this.panelCtrl = this.queryCtrl.panelCtrl;
     this.target = this.queryCtrl.target;
     this.target = this.queryCtrl.target;
     this.panel = this.panelCtrl.panel;
     this.panel = this.panelCtrl.panel;
-    this.hideEditorRowActions = this.panelCtrl.hideEditorRowActions;
 
 
-    if (!this.target.refId) {
-      this.target.refId = this.panel.getNextQueryLetter();
+    if (this.hasTextEditMode) {
+      // expose this function to react parent component
+      this.panelCtrl.toggleEditorMode = this.queryCtrl.toggleEditorMode.bind(this.queryCtrl);
     }
     }
 
 
-    this.toggleCollapse(true);
-    if (this.target.isNew) {
-      delete this.target.isNew;
-      this.toggleCollapse(false);
+    if (this.queryCtrl.getCollapsedText) {
+      // expose this function to react parent component
+      this.panelCtrl.getCollapsedText = this.queryCtrl.getCollapsedText.bind(this.queryCtrl);
     }
     }
-
-    if (this.panel.targets.length < 4) {
-      this.collapsed = false;
-    }
-  }
-
-  toggleHideQuery() {
-    this.target.hide = !this.target.hide;
-    this.panelCtrl.refresh();
-  }
-
-  toggleCollapse(init) {
-    if (!this.canCollapse) {
-      return;
-    }
-
-    if (!this.panelCtrl.__collapsedQueryCache) {
-      this.panelCtrl.__collapsedQueryCache = {};
-    }
-
-    if (init) {
-      this.collapsed = this.panelCtrl.__collapsedQueryCache[this.target.refId] !== false;
-    } else {
-      this.collapsed = !this.collapsed;
-      this.panelCtrl.__collapsedQueryCache[this.target.refId] = this.collapsed;
-    }
-
-    try {
-      this.collapsedText = this.queryCtrl.getCollapsedText();
-    } catch (e) {
-      const err = e.message || e.toString();
-      this.collapsedText = 'Error: ' + err;
-    }
-  }
-
-  toggleEditorMode() {
-    if (this.canCollapse && this.collapsed) {
-      this.collapsed = false;
-    }
-
-    this.queryCtrl.toggleEditorMode();
-  }
-
-  removeQuery() {
-    if (this.panelCtrl.__collapsedQueryCache) {
-      delete this.panelCtrl.__collapsedQueryCache[this.target.refId];
-    }
-
-    this.panelCtrl.removeQuery(this.target);
-  }
-
-  duplicateQuery() {
-    const clone = angular.copy(this.target);
-    this.panelCtrl.addQuery(clone);
-  }
-
-  moveQuery(direction) {
-    this.panelCtrl.moveQuery(this.target, direction);
   }
   }
 }
 }
 
 

+ 11 - 17
public/app/features/plugins/plugin_component.ts

@@ -105,23 +105,17 @@ function pluginDirectiveLoader($compile, datasourceSrv, $rootScope, $q, $http, $
     switch (attrs.type) {
     switch (attrs.type) {
       // QueryCtrl
       // QueryCtrl
       case 'query-ctrl': {
       case 'query-ctrl': {
-        const datasource = scope.target.datasource || scope.ctrl.panel.datasource;
-        return datasourceSrv.get(datasource).then(ds => {
-          scope.datasource = ds;
-
-          return importPluginModule(ds.meta.module).then(dsModule => {
-            return {
-              baseUrl: ds.meta.baseUrl,
-              name: 'query-ctrl-' + ds.meta.id,
-              bindings: { target: '=', panelCtrl: '=', datasource: '=' },
-              attrs: {
-                target: 'target',
-                'panel-ctrl': 'ctrl',
-                datasource: 'datasource',
-              },
-              Component: dsModule.QueryCtrl,
-            };
-          });
+        const ds = scope.ctrl.datasource;
+        return $q.when({
+          baseUrl: ds.meta.baseUrl,
+          name: 'query-ctrl-' + ds.meta.id,
+          bindings: { target: '=', panelCtrl: '=', datasource: '=' },
+          attrs: {
+            target: 'ctrl.target',
+            'panel-ctrl': 'ctrl',
+            datasource: 'ctrl.datasource',
+          },
+          Component: ds.pluginExports.QueryCtrl,
         });
         });
       }
       }
       // Annotations
       // Annotations

+ 4 - 0
public/app/plugins/datasource/graphite/query_ctrl.ts

@@ -391,6 +391,10 @@ export class GraphiteQueryCtrl extends QueryCtrl {
     this.paused = false;
     this.paused = false;
     this.panelCtrl.refresh();
     this.panelCtrl.refresh();
   }
   }
+
+  getCollapsedText() {
+    return this.target.target;
+  }
 }
 }
 
 
 function mapToDropdownOptions(results) {
 function mapToDropdownOptions(results) {

+ 3 - 3
public/app/plugins/datasource/postgres/partials/query.editor.html

@@ -138,9 +138,9 @@
     <pre class="gf-form-pre alert alert-info">Time series:
     <pre class="gf-form-pre alert alert-info">Time series:
 - return column named <i>time</i> (UTC in seconds or timestamp)
 - return column named <i>time</i> (UTC in seconds or timestamp)
 - return column(s) with numeric datatype as values
 - return column(s) with numeric datatype as values
-Optional: 
-  - return column named <i>metric</i> to represent the series name. 
-  - If multiple value columns are returned the metric column is used as prefix. 
+Optional:
+  - return column named <i>metric</i> to represent the series name.
+  - If multiple value columns are returned the metric column is used as prefix.
   - If no column named metric is found the column name of the value column is used as series name
   - If no column named metric is found the column name of the value column is used as series name
 
 
 Resultsets of time series queries need to be sorted by time.
 Resultsets of time series queries need to be sorted by time.

+ 1 - 0
public/app/types/plugins.ts

@@ -4,6 +4,7 @@ import { PanelProps, PanelOptionsProps } from '@grafana/ui';
 export interface PluginExports {
 export interface PluginExports {
   Datasource?: any;
   Datasource?: any;
   QueryCtrl?: any;
   QueryCtrl?: any;
+  QueryEditor?: any;
   ConfigCtrl?: any;
   ConfigCtrl?: any;
   AnnotationsQueryCtrl?: any;
   AnnotationsQueryCtrl?: any;
   VariableQueryEditor?: any;
   VariableQueryEditor?: any;

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

@@ -1,4 +1,4 @@
-import { PluginMeta } from './plugins';
+import { PluginMeta, PluginExports } from './plugins';
 import { TimeSeries, TimeRange, RawTimeRange } from '@grafana/ui';
 import { TimeSeries, TimeRange, RawTimeRange } from '@grafana/ui';
 
 
 export interface DataQueryResponse {
 export interface DataQueryResponse {
@@ -25,6 +25,10 @@ export interface DataQueryOptions {
 }
 }
 
 
 export interface DataSourceApi {
 export interface DataSourceApi {
+  name: string;
+  meta: PluginMeta;
+  pluginExports: PluginExports;
+
   /**
   /**
    *  min interval range
    *  min interval range
    */
    */

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

@@ -35,6 +35,7 @@
   flex-grow: 1;
   flex-grow: 1;
   background: $input-bg;
   background: $input-bg;
   margin: 0 20px 0 84px;
   margin: 0 20px 0 84px;
+  width: calc(100% - 84px);
   border-radius: 3px;
   border-radius: 3px;
   box-shadow: $panel-editor-shadow;
   box-shadow: $panel-editor-shadow;
   min-height: 0;
   min-height: 0;

+ 95 - 12
public/sass/components/_query_editor.scss

@@ -3,12 +3,6 @@
   color: $blue;
   color: $blue;
 }
 }
 
 
-.gf-form-disabled {
-  .query-keyword {
-    color: darken($blue, 20%);
-  }
-}
-
 .query-segment-operator {
 .query-segment-operator {
   color: $orange;
   color: $orange;
 }
 }
@@ -18,12 +12,6 @@
 }
 }
 
 
 .gf-form-query {
 .gf-form-query {
-  display: flex;
-  flex-direction: row;
-  flex-wrap: nowrap;
-  align-content: flex-start;
-  align-items: flex-start;
-
   .gf-form,
   .gf-form,
   .gf-form-filler {
   .gf-form-filler {
     margin-bottom: 2px;
     margin-bottom: 2px;
@@ -188,3 +176,98 @@ input[type='text'].tight-form-func-param {
 .rst-literal-block .rst-text {
 .rst-literal-block .rst-text {
   display: block;
   display: block;
 }
 }
+
+.query-editor-row {
+  margin-bottom: 2px;
+
+  &:hover {
+    .query-editor-row__actions {
+      display: flex;
+    }
+  }
+
+  &--disabled {
+    .query-keyword {
+      color: darken($blue, 20%);
+    }
+  }
+
+}
+
+.query-editor-row__header {
+  display: flex;
+  padding: 4px 0px 4px 8px;
+  position: relative;
+  height: 35px;
+  background: $page-bg;
+  flex-wrap: nowrap;
+  align-items: center;
+}
+
+.query-editor-row__ref-id {
+  font-weight: $font-weight-semi-bold;
+  color: $blue;
+  font-size: $font-size-md;
+  cursor: pointer;
+  display: flex;
+  align-items: center;
+
+  i {
+    padding-right: 5px;
+    color: $text-muted;
+    position: relative;
+  }
+}
+
+.query-editor-row__collapsed-text {
+  padding: 0 10px;
+  display: flex;
+  align-items: center;
+  flex-grow: 1;
+  overflow: hidden;
+
+  > div {
+    color: $text-muted;
+    font-style: italic;
+    overflow: hidden;
+    white-space: nowrap;
+    text-overflow: ellipsis;
+    font-size: $font-size-sm;
+    min-width: 0;
+  }
+}
+
+.query-editor-row__actions {
+  flex-shrink: 0;
+  display: flex;
+  justify-content: flex-end;
+  color: $text-muted;
+}
+
+.query-editor-row__action {
+  margin-left: 3px;
+  background: transparent;
+  border: none;
+  box-shadow: none;
+
+  &:hover {
+    color: $text-color;
+  }
+}
+
+.query-editor-row__body {
+  margin: 0 0 10px 40px;
+  background: $page-bg;
+
+  &--collapsed {
+    display: none;
+  }
+}
+
+.query-editor-row__context-info {
+  font-style: italic;
+  font-size: $font-size-sm;
+  color: $text-muted;
+  padding-left: 10px;
+}
+