Merge pull request #667 from influxdata/editor-mode

Editor mode
pull/695/head
Will Piers 2016-12-14 14:58:35 -08:00 committed by GitHub
commit 880b69a3e1
20 changed files with 352 additions and 233 deletions

View File

@ -1,5 +1,8 @@
## v1.1.0 [unreleased]
### Features
1. [#610](https://github.com/influxdata/chronograf/issues/610): Add Ability to edit raw text queries in the Data Explorer
## v1.1.0-beta2 [2016-12-09]
### Features

View File

@ -34,12 +34,13 @@ export function deletePanel(panelId) {
};
}
export function addQuery(panelId) {
export function addQuery(panelId, options) {
return {
type: 'ADD_QUERY',
payload: {
panelId,
queryId: uuid.v4(),
options,
},
};
}
@ -118,6 +119,16 @@ export function chooseMeasurement(queryId, measurement) {
};
}
export function editRawText(queryId, rawText) {
return {
type: 'EDIT_RAW_TEXT',
payload: {
queryId,
rawText,
},
};
}
export function setTimeRange(range) {
window.localStorage.setItem('timeRange', JSON.stringify(range));

View File

@ -3,9 +3,10 @@ import classNames from 'classnames';
import QueryEditor from './QueryEditor';
import QueryTabItem from './QueryTabItem';
import RenamePanelModal from './RenamePanelModal';
import SimpleDropdown from 'src/shared/components/SimpleDropdown';
const {shape, func, bool, arrayOf} = PropTypes;
const Explorer = React.createClass({
const Panel = React.createClass({
propTypes: {
panel: shape({}).isRequired,
queries: arrayOf(shape({})).isRequired,
@ -14,7 +15,7 @@ const Explorer = React.createClass({
lower: PropTypes.string,
}).isRequired,
isExpanded: bool.isRequired,
onToggleExplorer: func.isRequired,
onTogglePanel: func.isRequired,
actions: shape({
chooseNamespace: func.isRequired,
chooseMeasurement: func.isRequired,
@ -44,12 +45,16 @@ const Explorer = React.createClass({
this.props.actions.addQuery();
},
handleAddRawQuery() {
this.props.actions.addQuery({rawText: `SELECT "fields" from "db"."rp"."measurement"`});
},
handleDeleteQuery(query) {
this.props.actions.deleteQuery(query.id);
},
handleSelectExplorer() {
this.props.onToggleExplorer(this.props.panel);
handleSelectPanel() {
this.props.onTogglePanel(this.props.panel);
},
handleDeletePanel(e) {
@ -79,16 +84,16 @@ const Explorer = React.createClass({
const {panel, isExpanded} = this.props;
return (
<div className={classNames('explorer', {active: isExpanded})}>
<div className="explorer--header" onClick={this.handleSelectExplorer}>
<div className="explorer--name">
<div className={classNames('panel', {active: isExpanded})}>
<div className="panel--header" onClick={this.handleSelectPanel}>
<div className="panel--name">
<span className="icon caret-right"></span>
{panel.name || "Graph"}
</div>
<div className="explorer--actions">
<div title="Export Queries to Dashboard" className="explorer--action"><span className="icon export"></span></div>
<div title="Rename Graph" className="explorer--action" onClick={this.openRenamePanelModal}><span className="icon pencil"></span></div>
<div title="Delete Graph" className="explorer--action" onClick={this.handleDeletePanel}><span className="icon trash"></span></div>
<div className="panel--actions">
{/* <div title="Export Queries to Dashboard" className="panel--action"><span className="icon export"></span></div> */}
<div title="Rename Graph" className="panel--action" onClick={this.openRenamePanelModal}><span className="icon pencil"></span></div>
<div title="Delete Graph" className="panel--action" onClick={this.handleDeletePanel}><span className="icon trash"></span></div>
</div>
</div>
{this.renderQueryTabList()}
@ -127,12 +132,13 @@ const Explorer = React.createClass({
},
renderQueryTabList() {
if (!this.props.isExpanded) {
const {isExpanded, queries} = this.props;
if (!isExpanded) {
return null;
}
return (
<div className="explorer--tabs">
{this.props.queries.map((q) => {
<div className="panel--tabs">
{queries.map((q) => {
const queryTabText = (q.measurement && q.fields.length !== 0) ? `${q.measurement}.${q.fields[0].field}` : 'Query';
return (
<QueryTabItem
@ -145,12 +151,30 @@ const Explorer = React.createClass({
/>
);
})}
<div className="explorer--tab" onClick={this.handleAddQuery}>
<span className="icon plus"></span>
</div>
{this.renderAddQuery()}
</div>
);
},
onChoose(item) {
switch (item.text) {
case 'Query Builder':
this.handleAddQuery();
break;
case 'Raw Text':
this.handleAddRawQuery();
break;
}
},
renderAddQuery() {
return (
<SimpleDropdown onChoose={this.onChoose} items={[{text: 'Query Builder'}, {text: 'Raw Text'}]} className="panel--tab-new">
<span className="icon plus"></span>
</SimpleDropdown>
);
},
});
export default Explorer;
export default Panel;

View File

@ -1,7 +1,7 @@
import React, {PropTypes} from 'react';
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import ExplorerList from './ExplorerList';
import PanelList from './PanelList';
import * as viewActions from '../actions/view';
const {string, func} = PropTypes;
@ -12,6 +12,7 @@ const PanelBuilder = React.createClass({
createPanel: func.isRequired,
deleteQuery: func.isRequired,
addQuery: func.isRequired,
editRawText: func.isRequired,
chooseNamespace: func.isRequired,
chooseMeasurement: func.isRequired,
toggleField: func.isRequired,
@ -31,15 +32,15 @@ const PanelBuilder = React.createClass({
},
render() {
const {width, actions} = this.props;
const {activePanelID, width, actions, setActivePanel} = this.props;
return (
<div className="panel-builder" style={{width}}>
<div className="btn btn-block btn-primary" onClick={this.handleCreateExploer}><span className="icon graphline"></span>&nbsp;&nbsp;Create Graph</div>
<ExplorerList
<PanelList
actions={actions}
setActivePanel={this.props.setActivePanel}
activePanelID={this.props.activePanelID}
setActivePanel={setActivePanel}
activePanelID={activePanelID}
/>
</div>
);

View File

@ -2,10 +2,10 @@ import React, {PropTypes} from 'react';
import {connect} from 'react-redux';
import _ from 'lodash';
import Explorer from './Explorer';
import Panel from './Panel';
const {func, string, shape} = PropTypes;
const ExplorerList = React.createClass({
const PanelList = React.createClass({
propTypes: {
timeRange: shape({
upper: string,
@ -18,9 +18,9 @@ const ExplorerList = React.createClass({
activePanelID: string,
},
handleToggleExplorer(panel) {
// If the explorer being toggled is currently active, it means we should
// close everything by setting `activeExplorerIndex` to null.
handleTogglePanel(panel) {
// If the panel being toggled is currently active, it means we should
// close everything by setting `activePanelID` to null.
const activePanelID = panel.id === this.props.activePanelID ?
null : panel.id;
@ -45,12 +45,12 @@ const ExplorerList = React.createClass({
});
return (
<Explorer
<Panel
key={panelID}
panel={panel}
queries={queries}
timeRange={timeRange}
onToggleExplorer={this.handleToggleExplorer}
onTogglePanel={this.handleTogglePanel}
isExpanded={panelID === activePanelID}
actions={allActions}
/>
@ -69,4 +69,4 @@ function mapStateToProps(state) {
};
}
export default connect(mapStateToProps)(ExplorerList);
export default connect(mapStateToProps)(PanelList);

View File

@ -7,6 +7,7 @@ import DatabaseList from './DatabaseList';
import MeasurementList from './MeasurementList';
import FieldList from './FieldList';
import TagList from './TagList';
import RawQueryEditor from './RawQueryEditor';
const DB_TAB = 'databases';
const MEASUREMENTS_TAB = 'measurments';
@ -86,37 +87,39 @@ const QueryEditor = React.createClass({
this.props.actions.groupByTag(this.props.query.id, tagKey);
},
handleEditRawText(text) {
this.props.actions.editRawText(this.props.query.id, text);
},
handleClickTab(tab) {
this.setState({activeTab: tab});
},
render() {
const {query, timeRange} = this.props;
const statement = query.rawText || selectStatement(timeRange, query) || `SELECT "fields" FROM "db"."rp"."measurement"`;
return (
<div className="explorer--tab-contents">
<div className="qeditor--query-preview">
<pre className={classNames("", {"rq-mode": query.rawText})}><code>{statement}</code></pre>
</div>
{this.renderEditor()}
<div className="panel--tab-contents">
{this.renderQuery()}
{this.renderLists()}
</div>
);
},
renderEditor() {
if (this.props.query.rawText) {
renderQuery() {
const {query, timeRange} = this.props;
const statement = query.rawText || selectStatement(timeRange, query) || `SELECT "fields" FROM "db"."rp"."measurement"`;
if (!query.rawText) {
return (
<div className="qeditor--empty">
<p className="margin-bottom-zero">
<span className="icon alert-triangle"></span>
&nbsp;Only editable in the <strong>Raw Query</strong> tab.
</p>
<div className="qeditor--query-preview">
<pre><code>{statement}</code></pre>
</div>
);
}
return <RawQueryEditor query={query} onUpdate={this.handleEditRawText} />;
},
renderLists() {
const {activeTab} = this.state;
return (
<div>

View File

@ -23,9 +23,9 @@ const QueryTabItem = React.createClass({
render() {
return (
<div className={classNames('explorer--tab', {active: this.props.isActive})} onClick={this.handleSelect}>
<span className="explorer--tab-label">{this.props.query.rawText ? 'Raw Text' : this.props.queryTabText}</span>
<span className="explorer--tab-delete" onClick={this.handleDelete}></span>
<div className={classNames('panel--tab', {active: this.props.isActive})} onClick={this.handleSelect}>
<span className="panel--tab-label">{this.props.query.rawText ? 'Raw Text' : this.props.queryTabText}</span>
<span className="panel--tab-delete" onClick={this.handleDelete}></span>
</div>
);
},

View File

@ -0,0 +1,66 @@
import React, {PropTypes} from 'react';
const ENTER = 13;
const ESCAPE = 27;
const RawQueryEditor = React.createClass({
propTypes: {
query: PropTypes.shape({
rawText: PropTypes.string.isRequired,
id: PropTypes.string.isRequired,
}).isRequired,
onUpdate: PropTypes.func.isRequired,
},
getInitialState() {
return {
value: this.props.query.rawText,
};
},
componentWillReceiveProps(nextProps) {
if (nextProps.query.rawText !== this.props.query.rawText) {
this.setState({value: nextProps.query.rawText});
}
},
handleKeyDown(e) {
if (e.keyCode === ENTER) {
this.handleUpdate();
this.editor.blur();
} else if (e.keyCode === ESCAPE) {
this.setState({value: this.props.query.rawText}, () => {
this.editor.blur();
});
}
},
handleChange() {
this.setState({
value: this.editor.value,
});
},
handleUpdate() {
this.props.onUpdate(this.state.value);
},
render() {
const {value} = this.state;
return (
<div className="raw-text">
<textarea
className="raw-text--field"
onChange={this.handleChange}
onKeyDown={this.handleKeyDown}
onBlur={this.handleUpdate}
ref={(editor) => this.editor = editor}
value={value}
placeholder="Blank query"
/>
</div>
);
},
});
export default RawQueryEditor;

View File

@ -1,5 +1,6 @@
import defaultQueryConfig from 'src/utils/defaultQueryConfig';
import {
editRawText,
applyFuncsToField,
chooseMeasurement,
chooseNamespace,
@ -20,10 +21,10 @@ export default function queryConfigs(state = {}, action) {
case 'CHOOSE_NAMESPACE': {
const {queryId, database, retentionPolicy} = action.payload;
const nextQueryConfig = chooseNamespace(defaultQueryConfig(queryId), {database, retentionPolicy});
const nextQueryConfig = chooseNamespace(state[queryId], {database, retentionPolicy});
return Object.assign({}, state, {
[queryId]: nextQueryConfig,
[queryId]: Object.assign(nextQueryConfig, {rawText: state[queryId].rawText}),
});
}
@ -32,7 +33,7 @@ export default function queryConfigs(state = {}, action) {
const nextQueryConfig = chooseMeasurement(state[queryId], measurement);
return Object.assign({}, state, {
[queryId]: nextQueryConfig,
[queryId]: Object.assign(nextQueryConfig, {rawText: state[queryId].rawText}),
});
}
@ -48,9 +49,9 @@ export default function queryConfigs(state = {}, action) {
case 'CREATE_PANEL':
case 'ADD_KAPACITOR_QUERY':
case 'ADD_QUERY': {
const {queryId} = action.payload;
const {queryId, options} = action.payload;
const nextState = Object.assign({}, state, {
[queryId]: defaultQueryConfig(queryId),
[queryId]: Object.assign({}, defaultQueryConfig(queryId), options),
});
return nextState;
@ -65,6 +66,15 @@ export default function queryConfigs(state = {}, action) {
return nextState;
}
case 'EDIT_RAW_TEXT': {
const {queryId, rawText} = action.payload;
const nextQueryConfig = editRawText(state[queryId], rawText);
return Object.assign({}, state, {
[queryId]: nextQueryConfig,
});
}
case 'GROUP_BY_TIME': {
const {queryId, time} = action.payload;
const nextQueryConfig = groupByTime(state[queryId], time);

View File

@ -0,0 +1,59 @@
import React, {PropTypes} from 'react';
import classNames from 'classnames';
import OnClickOutside from 'shared/components/OnClickOutside';
const Dropdown = React.createClass({
propTypes: {
children: PropTypes.node.isRequired,
items: PropTypes.arrayOf(PropTypes.shape({
text: PropTypes.string.isRequired,
})).isRequired,
onChoose: PropTypes.func.isRequired,
className: PropTypes.string,
},
getInitialState() {
return {
isOpen: false,
};
},
handleClickOutside() {
this.setState({isOpen: false});
},
handleSelection(item) {
this.toggleMenu();
this.props.onChoose(item);
},
toggleMenu(e) {
if (e) {
e.stopPropagation();
}
this.setState({isOpen: !this.state.isOpen});
},
render() {
const self = this;
const {items, className} = self.props;
return (
<div onClick={this.toggleMenu} className={classNames(`dropdown ${className}`, {open: self.state.isOpen})}>
<div className="btn btn-sm btn-info dropdown-toggle">
{this.props.children}
</div>
{self.state.isOpen ?
<ul className="dropdown-menu show">
{items.map((item, i) => {
return (
<li className="dropdown-item" key={i} onClick={() => self.handleSelection(item)}>
<a href="#">
{item.text}
</a>
</li>
);
})}
</ul>
: null}
</div>
);
},
});
export default OnClickOutside(Dropdown);

View File

@ -1,18 +1,19 @@
.explorer {
.panel {
display: block;
background-color: $g3-castle;
border-radius: $radius;
margin-bottom: 6px;
transition: background-color 0.25s ease;
border: 0;
&:hover {
background-color: $g4-onyx;
}
// For when an explorer item is open
// For when an panel item is open
&.active {
background-color: $g4-onyx;
.explorer__header {
.panel__header {
&-name {
color: $g20-white;
@ -23,8 +24,8 @@
}
}
}
// Explorer Header Bar
.explorer--header {
// panel Header Bar
.panel--header {
align-items: center;
text-align: center;
display: flex;
@ -35,7 +36,7 @@
justify-content: space-between;
border-radius: $radius;
}
.explorer--name {
.panel--name {
color: $g13-mist;
font-weight: 600;
font-size: 14px;
@ -56,11 +57,11 @@
color: $g17-whisper;
}
}
.explorer--actions {
.panel--actions {
display: flex;
align-items: center;
}
.explorer--action {
.panel--action {
width: 24px;
height: 24px;
border: 0;
@ -76,12 +77,12 @@
}
// Tabs
.explorer--tabs {
.panel--tabs {
display: flex;
background-color: $g4-onyx;
padding: 0 11px;
}
.explorer--tab {
.panel--tab {
display: flex;
align-items: center;
color: $g11-sidewalk;
@ -104,12 +105,6 @@
color: $g15-platinum;
}
> span.icon.plus {
font-size: 12px;
position: relative;
top: -1px;
}
&-delete {
margin: 0 -4px 0 1px;
width: 16px;
@ -147,7 +142,25 @@
}
}
}
.explorer--tab-label {
.panel--tab-new {
> .dropdown-toggle {
height: 28px !important;
border-radius: $radius $radius 0 0;
> .icon {
margin: 0;
font-size: 12px;
position: relative;
top: -1px;
}
}
> .dropdown-menu {
width: 108px !important;
min-width: 108px !important;
max-width: 108px !important;
}
}
.panel--tab-label {
display: inline-block;
font-size: 12px;
font-weight: 600;
@ -161,7 +174,7 @@
Tab Contents
-------------------------------------------
*/
.explorer--tab-contents {
.panel--tab-contents {
padding: 6px;
background-color: $g6-smoke;
border-radius: 0 0 $radius $radius;
@ -177,7 +190,7 @@
}
}
.explorer__header-actions {
.panel__header-actions {
display: flex;
* {

View File

@ -15,11 +15,9 @@ $query-editor-height: 250px;
pre {
padding: 9px;
white-space: pre-wrap;
border: 0;
background-color: $query-editor-tab-inactive;
font-weight: 600;
color: $c-comet;
color: $c-pool;
border-radius: $radius-small $radius-small 0 0;
border-bottom: 2px solid $query-editor-tab-active;
margin-bottom: 0;
@ -30,11 +28,7 @@ $query-editor-height: 250px;
code {
white-space: pre-wrap;
line-height: 1.5em;
}
&.rq-mode {
color: $c-rainforest;
@include custom-scrollbar($query-editor-tab-inactive, $c-rainforest);
margin: 0;
}
}
}

View File

@ -1,144 +0,0 @@
.raw-query-editor {
width: 100%;
height: 84px;
background-color: transparent;
border: 0;
color: $c-comet;
padding: 6px 6px 6px 12px;
border-radius: 0 3px 3px 0;
font-family: Menlo, Monaco, Consolas, "Courier New", monospace;
font-size: 13px;
border-left: 2px solid $g6-smoke;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
resize: none;
margin: 0 0 4px 0;
transition:
color 0.25s ease,
background-color 0.25s ease,
border-color 0.25s ease;
&::-webkit-input-placeholder { /* Chrome/Opera/Safari */
color: $g8-storm;
}
&::-moz-placeholder { /* Firefox 19+ */
color: $g8-storm;
}
&:-ms-input-placeholder { /* IE 10+ */
color: $g8-storm;
}
&:-moz-placeholder { /* Firefox 18- */
color: $g8-storm;
}
&:focus {
outline: none;
background-color: $g6-smoke !important;
color: $g18-cloud !important;
border-color: $g8-storm !important;
}
}
.raw-query-editor-wrapper {
position: relative;
&:hover {
.raw-query-editor {
background-color: fade-out($g5-pepper, 0.5);
}
.raw-query-editor-delete {
opacity: 1;
}
}
&.rq-mode {
.raw-query-editor {
color: $c-honeydew;
}
&:after {
content: 'RQ';
position: absolute;
width: 24px;
height: 24px;
background-color: $c-honeydew;
color: $g6-smoke;
z-index: 3;
bottom: 12px;
right: 2px;
border-radius: 3px;
text-align: center;
line-height: 25px;
font-size: 13px;
font-weight: 700;
}
}
}
.raw-query-editor-delete {
position: absolute;
top: 0;
right: 0;
z-index: 2;
width: 24px;
height: 24px;
background-color: transparent;
transition:
opacity 0.25s ease,
color 0.25s ease;
border: 0;
color: $g8-storm;
opacity: 0;
font-size: 13px;
> .icon {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%,-50%);
}
&:hover {
color: $g20-white;
}
}
.raw-editor {
&__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
&-name {
color: $g20-white;
font-weight: 700;
font-size: 16px;
}
&-actions {
display: flex;
align-items: center;
justify-content: flex-end;
> * {
transition:
color 0.25s ease;
margin-left: 10px;
color: $g8-storm;
&:hover {
cursor: pointer;
color: $g20-white;
}
}
.raw-editor__header-delete:hover {
color: $c-dreamsicle;
}
}
}
&__panel {
margin-top: 20px;
padding-top: 5px;
&:first-child {
border: none;
padding-top: 0;
}
}
}

View File

@ -0,0 +1,67 @@
/*
Dropping the metaphorical CSS nuke here,
was experiencing some weird typographic jank
between builder / raw tabs
*/
.raw-text--field,
.qeditor--query-preview pre,
.qeditor--query-preview pre code {
font-style: normal !important;
letter-spacing: 0.02em !important;
font-size: 12px !important;
font-family: 'RobotoMono', monospace !important;
font-weight: 600 !important;
word-wrap: break-word !important;
word-break: break-all !important;
white-space: pre-wrap !important;
-webkit-font-smoothing: antialiased;
}
$raw-text-color: $c-comet;
.raw-text {
border-bottom: 2px solid $g4-onyx;
}
.raw-text--field {
@include custom-scrollbar($g2-kevlar, $raw-text-color);
display: block;
width: 100%;
height: 100px;
background-color: $g2-kevlar;
border: 2px solid $g2-kevlar;
color: $raw-text-color;
padding: (9px - 2px);
border-radius: 3px 3px 0 0;
line-height: 1.5em;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
resize: none;
margin: 0;
transition:
color 0.25s ease,
background-color 0.25s ease,
border-color 0.25s ease;
&::-webkit-input-placeholder { /* Chrome/Opera/Safari */
color: $g8-storm;
}
&::-moz-placeholder { /* Firefox 19+ */
color: $g8-storm;
}
&:-ms-input-placeholder { /* IE 10+ */
color: $g8-storm;
}
&:-moz-placeholder { /* Firefox 18- */
color: $g8-storm;
}
&:hover {
background-color: $g3-castle;
border-color: $g3-castle;
}
&:focus {
outline: none;
color: $raw-text-color !important;
border-color: $c-pool;
}
}

View File

@ -4,13 +4,13 @@
@import 'components/QueryEditor';
@import 'components/PanelBuilder';
@import 'components/Explorer';
@import 'components/Panel';
@import 'components/MultiSelectDropdown';
@import 'components/GroupByTimeDropdown';
@import 'components/TagList';
@import 'components/Resizer';
@import 'components/Header';
@import 'components/RawQueryEditor';
@import 'components/RawText';
@import 'components/Visualization';
@import 'components/Tasks';
@import 'components/spinner';

View File

@ -434,10 +434,10 @@ $form-static-checkbox-size: 16px;
----------------------------------------------
*/
table .monotype {
font-family: Consolas, "Lucida Console", Monaco, monospace;
font-family: 'RobotoMono', monospace !important;
letter-spacing: 0.69px;
font-size: 12px;
font-weight: 700;
font-weight: 500;
color: $g9-mountain;
}
.table-dot {

View File

@ -34,3 +34,9 @@
font-weight: 700;
src: url('fonts/Roboto-Black.ttf');
}
@font-face {
font-family: 'RobotoMono';
font-style: normal;
font-weight: 500;
src: url('fonts/RobotoMono-Medium.ttf');
}

Binary file not shown.

View File

@ -323,7 +323,9 @@ textarea {
Dark Code Samples
----------------------------------------------
*/
code, pre {
font-family: 'RobotoMono', monospace !important;
}
code {
display: inline-block;
background-color: $g2-kevlar;

View File

@ -1,7 +1,11 @@
import defaultQueryConfig from './defaultQueryConfig';
export function editRawText(query, rawText) {
return Object.assign({}, query, {rawText});
}
export function chooseNamespace(query, namespace) {
return Object.assign({}, query, namespace);
return Object.assign({}, defaultQueryConfig(query.id), namespace);
}
export function chooseMeasurement(query, measurement) {