diff --git a/packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.tsx b/packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.tsx index 7f43936481f..c3fb3f0f0ab 100644 --- a/packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.tsx +++ b/packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.tsx @@ -1,4 +1,5 @@ import React, { PureComponent } from 'react'; +import _ from 'lodash'; import Scrollbars from 'react-custom-scrollbars'; interface Props { @@ -8,6 +9,8 @@ interface Props { autoHideDuration?: number; autoMaxHeight?: string; hideTracksWhenNotNeeded?: boolean; + scrollTop?: number; + setScrollTop: (value: React.MouseEvent) => void; autoHeightMin?: number | string; } @@ -22,14 +25,44 @@ export class CustomScrollbar extends PureComponent { autoHideDuration: 200, autoMaxHeight: '100%', hideTracksWhenNotNeeded: false, + scrollTop: 0, + setScrollTop: () => {}, autoHeightMin: '0' }; + private ref: React.RefObject; + + constructor(props: Props) { + super(props); + this.ref = React.createRef(); + } + + 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() { const { customClassName, children, autoMaxHeight } = this.props; return ( JSX.Element; toolbarItems?: EditorToolbarView[]; + scrollTop?: number; + setScrollTop?: (value: React.MouseEvent) => void; } export interface EditorToolbarView { @@ -103,7 +105,7 @@ export class EditorTabBody extends PureComponent { } render() { - const { children, renderToolbar, heading, toolbarItems } = this.props; + const { children, renderToolbar, heading, toolbarItems, scrollTop, setScrollTop } = this.props; const { openView, fadeIn, isOpen } = this.state; return ( @@ -119,7 +121,7 @@ export class EditorTabBody extends PureComponent { )}
- +
{openView && this.renderOpenView(openView)} diff --git a/public/app/features/dashboard/panel_editor/QueriesTab.tsx b/public/app/features/dashboard/panel_editor/QueriesTab.tsx index 47c4f358136..7d2f77aa338 100644 --- a/public/app/features/dashboard/panel_editor/QueriesTab.tsx +++ b/public/app/features/dashboard/panel_editor/QueriesTab.tsx @@ -3,18 +3,16 @@ import React, { PureComponent } from 'react'; import _ from 'lodash'; // Components -import 'app/features/panel/metrics_tab'; import { EditorTabBody, EditorToolbarView } 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 { PanelOptionsGroup } from '@grafana/ui'; +import { QueryEditorRow } from './QueryEditorRow'; // Services import { getDatasourceSrv } from 'app/features/plugins/datasource_srv'; import { BackendSrv, getBackendSrv } from 'app/core/services/backend_srv'; -import { AngularComponent, getAngularLoader } from 'app/core/services/AngularLoader'; import config from 'app/core/config'; // Types @@ -34,66 +32,27 @@ interface State { isLoadingHelp: boolean; isPickerOpen: boolean; isAddingMixed: boolean; + scrollTop: number; } export class QueriesTab extends PureComponent { - element: HTMLElement; - component: AngularComponent; datasources: DataSourceSelectItem[] = getDatasourceSrv().getMetricSources(); 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 { const { panel } = this.props; 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 = ''; - const scopeProps = { - ctrl: this.getAngularQueryComponentScope(), - }; - - this.component = loader.load(this.element, scopeProps, template); - } - - componentWillUnmount() { - if (this.component) { - this.component.destroy(); - } - } - onChangeDataSource = datasource => { const { panel } = this.props; const { currentDS } = this.state; @@ -137,7 +96,7 @@ export class QueriesTab extends PureComponent { onAddQuery = (query?: Partial) => { this.props.panel.addQuery(query); - this.forceUpdate(); + this.setState({ scrollTop: this.state.scrollTop + 100000 }); }; onAddQueryClick = () => { @@ -146,9 +105,7 @@ export class QueriesTab extends PureComponent { return; } - this.props.panel.addQuery(); - this.component.digest(); - this.forceUpdate(); + this.onAddQuery(); }; onRemoveQuery = (query: DataQuery) => { @@ -171,9 +128,21 @@ export class QueriesTab extends PureComponent { }; renderToolbar = () => { - const { currentDS } = this.state; + const { currentDS, isAddingMixed } = this.state; - return ; + return ( + <> + +
+ {!isAddingMixed && ( + + )} + {isAddingMixed && this.renderMixedPicker()} +
+ + ); }; renderMixedPicker = () => { @@ -190,17 +159,21 @@ export class QueriesTab extends PureComponent { onAddMixedQuery = datasource => { this.onAddQuery({ datasource: datasource.name }); - this.component.digest(); - this.setState({ isAddingMixed: false }); + this.setState({ isAddingMixed: false, scrollTop: this.state.scrollTop + 10000 }); }; onMixedPickerBlur = () => { this.setState({ isAddingMixed: false }); }; + setScrollTop = (event: React.MouseEvent) => { + const target = event.target as HTMLElement; + this.setState({ scrollTop: target.scrollTop }); + }; + render() { const { panel } = this.props; - const { currentDS, isAddingMixed } = this.state; + const { currentDS, scrollTop } = this.state; const queryInspector: EditorToolbarView = { title: 'Query Inspector', @@ -214,32 +187,28 @@ export class QueriesTab extends PureComponent { }; return ( - + <> - -
-
(this.element = element)} /> - -
-
- -
-
- {!isAddingMixed && ( - - )} - {isAddingMixed && this.renderMixedPicker()} -
-
-
- +
+ {panel.targets.map((query, index) => ( + + ))} +
diff --git a/public/app/features/dashboard/panel_editor/QueryEditorRow.tsx b/public/app/features/dashboard/panel_editor/QueryEditorRow.tsx new file mode 100644 index 00000000000..410d0029b15 --- /dev/null +++ b/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 { + 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 = ''; + 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
(this.element = element)} />; + } + + if (datasource.pluginExports.QueryEditor) { + const QueryEditor = datasource.pluginExports.QueryEditor; + return ; + } + + return
Data source plugin does not export any Query Editor component
; + } + + 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 ( +
+
+
+ {isCollapsed && } + {!isCollapsed && } + {query.refId} + {inMixedMode && ({datasourceName})} + {isDisabled && Disabled} +
+
+ {isCollapsed &&
{this.renderCollapsedText()}
} +
+
+ {this.hasTextEditMode && ( + + )} + + + + + +
+
+
{this.renderPluginEditor()}
+
+ ); + } +} + +export interface AngularQueryComponentScope { + target: DataQuery; + panel: PanelModel; + events: Emitter; + refresh: () => void; + render: () => void; + datasource: DataSourceApi; + toggleEditorMode?: () => void; + getCollapsedText?: () => string; +} diff --git a/public/app/features/dashboard/panel_editor/VisualizationTab.tsx b/public/app/features/dashboard/panel_editor/VisualizationTab.tsx index ad569a9ff90..64bf3165ddc 100644 --- a/public/app/features/dashboard/panel_editor/VisualizationTab.tsx +++ b/public/app/features/dashboard/panel_editor/VisualizationTab.tsx @@ -26,6 +26,7 @@ interface Props { interface State { isVizPickerOpen: boolean; searchQuery: string; + scrollTop: number; } export class VisualizationTab extends PureComponent { @@ -39,6 +40,7 @@ export class VisualizationTab extends PureComponent { this.state = { isVizPickerOpen: false, searchQuery: '', + scrollTop: 0, }; } @@ -143,7 +145,7 @@ export class VisualizationTab extends PureComponent { }; onOpenVizPicker = () => { - this.setState({ isVizPickerOpen: true }); + this.setState({ isVizPickerOpen: true, scrollTop: 0 }); }; onCloseVizPicker = () => { @@ -201,9 +203,14 @@ export class VisualizationTab extends PureComponent { renderHelp = () => ; + setScrollTop = (event: React.MouseEvent) => { + const target = event.target as HTMLElement; + this.setState({ scrollTop: target.scrollTop }); + }; + render() { const { plugin } = this.props; - const { isVizPickerOpen, searchQuery } = this.state; + const { isVizPickerOpen, searchQuery, scrollTop } = this.state; const pluginHelp: EditorToolbarView = { heading: 'Help', @@ -212,7 +219,8 @@ export class VisualizationTab extends PureComponent { }; return ( - + <> 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); diff --git a/public/app/features/panel/partials/metrics_tab.html b/public/app/features/panel/partials/metrics_tab.html deleted file mode 100644 index 5e9f23ba2ef..00000000000 --- a/public/app/features/panel/partials/metrics_tab.html +++ /dev/null @@ -1,24 +0,0 @@ -
- - - - -
- - - - - - - - - - - - - - - - - - diff --git a/public/app/features/panel/partials/query_editor_row.html b/public/app/features/panel/partials/query_editor_row.html index 34a86813d1d..fc2e3602630 100644 --- a/public/app/features/panel/partials/query_editor_row.html +++ b/public/app/features/panel/partials/query_editor_row.html @@ -1,44 +1,2 @@ -
- +
-
-
- -
-
- -
- -
- - - -
-
diff --git a/public/app/features/panel/query_editor_row.ts b/public/app/features/panel/query_editor_row.ts index a44c1e8be6d..fa25ce832be 100644 --- a/public/app/features/panel/query_editor_row.ts +++ b/public/app/features/panel/query_editor_row.ts @@ -3,89 +3,26 @@ import angular from 'angular'; const module = angular.module('grafana.directives'); export class QueryRowCtrl { - collapsedText: string; - canCollapse: boolean; - getCollapsedText: any; target: any; queryCtrl: any; panelCtrl: any; panel: any; - collapsed: any; - hideEditorRowActions: boolean; + hasTextEditMode: boolean; constructor() { this.panelCtrl = this.queryCtrl.panelCtrl; this.target = this.queryCtrl.target; 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); } } diff --git a/public/app/features/plugins/plugin_component.ts b/public/app/features/plugins/plugin_component.ts index 7092608085d..0b305e05f5b 100644 --- a/public/app/features/plugins/plugin_component.ts +++ b/public/app/features/plugins/plugin_component.ts @@ -105,23 +105,17 @@ function pluginDirectiveLoader($compile, datasourceSrv, $rootScope, $q, $http, $ switch (attrs.type) { // QueryCtrl 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 diff --git a/public/app/plugins/datasource/graphite/query_ctrl.ts b/public/app/plugins/datasource/graphite/query_ctrl.ts index fa908c5e955..b89e84d23a7 100644 --- a/public/app/plugins/datasource/graphite/query_ctrl.ts +++ b/public/app/plugins/datasource/graphite/query_ctrl.ts @@ -391,6 +391,10 @@ export class GraphiteQueryCtrl extends QueryCtrl { this.paused = false; this.panelCtrl.refresh(); } + + getCollapsedText() { + return this.target.target; + } } function mapToDropdownOptions(results) { diff --git a/public/app/plugins/datasource/postgres/partials/query.editor.html b/public/app/plugins/datasource/postgres/partials/query.editor.html index 6c3bf02cb51..5d866245aff 100644 --- a/public/app/plugins/datasource/postgres/partials/query.editor.html +++ b/public/app/plugins/datasource/postgres/partials/query.editor.html @@ -138,9 +138,9 @@
Time series:
 - return column named time (UTC in seconds or timestamp)
 - return column(s) with numeric datatype as values
-Optional: 
-  - return column named metric to represent the series name. 
-  - If multiple value columns are returned the metric column is used as prefix. 
+Optional:
+  - return column named metric 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
 
 Resultsets of time series queries need to be sorted by time.
diff --git a/public/app/types/plugins.ts b/public/app/types/plugins.ts
index a1403c7a71c..4dacb3f8ccb 100644
--- a/public/app/types/plugins.ts
+++ b/public/app/types/plugins.ts
@@ -4,6 +4,7 @@ import { PanelProps, PanelOptionsProps } from '@grafana/ui';
 export interface PluginExports {
   Datasource?: any;
   QueryCtrl?: any;
+  QueryEditor?: any;
   ConfigCtrl?: any;
   AnnotationsQueryCtrl?: any;
   VariableQueryEditor?: any;
diff --git a/public/app/types/series.ts b/public/app/types/series.ts
index 9fe68955da5..6f1795ef544 100644
--- a/public/app/types/series.ts
+++ b/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';
 
 export interface DataQueryResponse {
@@ -25,6 +25,10 @@ export interface DataQueryOptions {
 }
 
 export interface DataSourceApi {
+  name: string;
+  meta: PluginMeta;
+  pluginExports: PluginExports;
+
   /**
    *  min interval range
    */
diff --git a/public/sass/components/_panel_editor.scss b/public/sass/components/_panel_editor.scss
index b2ab91ccb19..b791231a242 100644
--- a/public/sass/components/_panel_editor.scss
+++ b/public/sass/components/_panel_editor.scss
@@ -35,6 +35,7 @@
   flex-grow: 1;
   background: $input-bg;
   margin: 0 20px 0 84px;
+  width: calc(100% - 84px);
   border-radius: 3px;
   box-shadow: $panel-editor-shadow;
   min-height: 0;
diff --git a/public/sass/components/_query_editor.scss b/public/sass/components/_query_editor.scss
index 8b876624294..8d72f901b6e 100644
--- a/public/sass/components/_query_editor.scss
+++ b/public/sass/components/_query_editor.scss
@@ -3,12 +3,6 @@
   color: $blue;
 }
 
-.gf-form-disabled {
-  .query-keyword {
-    color: darken($blue, 20%);
-  }
-}
-
 .query-segment-operator {
   color: $orange;
 }
@@ -18,12 +12,6 @@
 }
 
 .gf-form-query {
-  display: flex;
-  flex-direction: row;
-  flex-wrap: nowrap;
-  align-content: flex-start;
-  align-items: flex-start;
-
   .gf-form,
   .gf-form-filler {
     margin-bottom: 2px;
@@ -188,3 +176,98 @@ input[type='text'].tight-form-func-param {
 .rst-literal-block .rst-text {
   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;
+}
+