diff --git a/CHANGELOG.md b/CHANGELOG.md index a4d70fb305..ee5ab299c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,9 @@ 1. [#3245](https://github.com/influxdata/chronograf/pull/3245): Display 'no results' on cells without results 1. [#3354](https://github.com/influxdata/chronograf/pull/3354): Disable template variables for non editing users 1. [#3353](https://github.com/influxdata/chronograf/pull/3353): YAxisLabels in Dashboard Graph Builder not showing until graph is redrawn +1. [#3378](https://github.com/influxdata/chronograf/pull/3378): Ensure table graphs have a consistent ux between chrome and firefox +1. [#3401](https://github.com/influxdata/chronograf/pull/3401): Change AutoRefresh interval to paused. +1. [#3404](https://github.com/influxdata/chronograf/pull/3404): Get cloned cell name for notification from cloned cell generator function ### Bug Fixes @@ -29,6 +32,8 @@ 1. [#3353](https://github.com/influxdata/chronograf/pull/3353): Display y-axis label on initial graph load 1. [#3352](https://github.com/influxdata/chronograf/pull/3352): Fix not being able to change the source in the CEO display 1. [#3357](https://github.com/influxdata/chronograf/pull/3357): Fix only the selected template variable value getting loaded +1. [#3389](https://github.com/influxdata/chronograf/pull/3389): Fix Generic OAuth bug for GitHub Enterprise where the principal was incorrectly being checked for email being Primary and Verified +1. [#3402](https://github.com/influxdata/chronograf/pull/3402): Fix missing icons when using basepath ## v1.4.4.1 [2018-04-16] diff --git a/oauth2/generic.go b/oauth2/generic.go index 54b4392fcb..c53c98c87e 100644 --- a/oauth2/generic.go +++ b/oauth2/generic.go @@ -165,28 +165,6 @@ type UserEmail struct { Verified *bool `json:"verified,omitempty"` } -// GetPrimary returns if the email is the primary email. -// If primary is not present, all emails are considered the primary. -func (u *UserEmail) GetPrimary() bool { - if u == nil { - return false - } else if u.Primary == nil { - return true - } - return *u.Primary -} - -// GetVerified returns if the email has been verified. -// If verified is not present, all emails are considered verified. -func (u *UserEmail) GetVerified() bool { - if u == nil { - return false - } else if u.Verified == nil { - return true - } - return *u.Verified -} - // getPrimaryEmail gets the private email account for the authenticated user. func (g *Generic) getPrimaryEmail(client *http.Client) (string, error) { emailsEndpoint := g.APIURL + "/emails" @@ -211,7 +189,7 @@ func (g *Generic) getPrimaryEmail(client *http.Client) (string, error) { func (g *Generic) primaryEmail(emails []*UserEmail) (string, error) { for _, m := range emails { - if m != nil && m.GetPrimary() && m.GetVerified() && m.Email != nil { + if m != nil && m.Primary != nil && m.Verified != nil && m.Email != nil { return *m.Email, nil } } diff --git a/oauth2/generic_test.go b/oauth2/generic_test.go index 33e54c5de6..89bfc88184 100644 --- a/oauth2/generic_test.go +++ b/oauth2/generic_test.go @@ -155,9 +155,7 @@ func TestGenericPrincipalIDDomain(t *testing.T) { Primary bool `json:"primary"` Verified bool `json:"verified"` }{ - {"mcfly@example.com", false, true}, - {"martymcspelledwrong@example.com", false, false}, - {"martymcfly@pinheads.rok", true, true}, + {"martymcfly@pinheads.rok", true, false}, } mockAPI := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { if r.URL.Path == "/" { diff --git a/server/swagger.json b/server/swagger.json index 8f2188cf16..070d6df480 100644 --- a/server/swagger.json +++ b/server/swagger.json @@ -3678,7 +3678,9 @@ "http", "hipchat", "opsgenie", + "opsgenie2", "pagerduty", + "pagerduty2", "victorops", "email", "exec", diff --git a/server/url_prefixer.go b/server/url_prefixer.go index 0a58436460..9d095fa121 100644 --- a/server/url_prefixer.go +++ b/server/url_prefixer.go @@ -5,6 +5,7 @@ import ( "bytes" "io" "net/http" + "regexp" "github.com/influxdata/chronograf" ) @@ -83,6 +84,12 @@ func (up *URLPrefixer) ServeHTTP(rw http.ResponseWriter, r *http.Request) { return } + isSVG, _ := regexp.Match(".svg$", []byte(r.URL.String())) + if isSVG { + up.Next.ServeHTTP(rw, r) + return + } + // chunked transfer because we're modifying the response on the fly, so we // won't know the final content-length rw.Header().Set("Connection", "Keep-Alive") diff --git a/ui/package.json b/ui/package.json index 62c4e0d5ca..0fa63f482a 100644 --- a/ui/package.json +++ b/ui/package.json @@ -38,6 +38,7 @@ }, "devDependencies": { "@types/chai": "^4.1.2", + "@types/codemirror": "^0.0.56", "@types/dygraphs": "^1.1.6", "@types/enzyme": "^3.1.9", "@types/jest": "^22.1.4", @@ -49,7 +50,6 @@ "@types/react-dnd-html5-backend": "^2.1.9", "@types/react-router": "^3.0.15", "@types/text-encoding": "^0.0.32", - "@types/codemirror": "^0.0.56", "autoprefixer": "^6.3.1", "babel-core": "^6.5.1", "babel-eslint": "6.1.2", diff --git a/ui/src/admin/components/chronograf/AllUsersTableRow.tsx b/ui/src/admin/components/chronograf/AllUsersTableRow.tsx index 479433eac0..6a2863d8f9 100644 --- a/ui/src/admin/components/chronograf/AllUsersTableRow.tsx +++ b/ui/src/admin/components/chronograf/AllUsersTableRow.tsx @@ -61,6 +61,7 @@ export default class AllUsersTableRow extends PureComponent { async dispatch => { try { - const {data} = await addDashboardCellAJAX( - dashboard, - getClonedDashboardCell(dashboard, cell) - ) + const clonedCell = getClonedDashboardCell(dashboard, cell) + const {data} = await addDashboardCellAJAX(dashboard, clonedCell) dispatch(addDashboardCell(dashboard, data)) - dispatch(notify(notifyCellCloned(cell.name))) + dispatch(notify(notifyCellAdded(clonedCell.name))) } catch (error) { console.error(error) dispatch(errorThrown(error)) diff --git a/ui/src/dashboards/utils/cellGetters.js b/ui/src/dashboards/utils/cellGetters.js index 44ba00abe4..4cbe6d638d 100644 --- a/ui/src/dashboards/utils/cellGetters.js +++ b/ui/src/dashboards/utils/cellGetters.js @@ -89,7 +89,7 @@ export const getNewDashboardCell = (dashboard, cellType) => { export const getClonedDashboardCell = (dashboard, cloneCell) => { const {x, y} = getNextAvailablePosition(dashboard, cloneCell) - const name = `${cloneCell.name} (Clone)` + const name = `${cloneCell.name} (clone)` return {...cloneCell, x, y, name} } diff --git a/ui/src/data_explorer/components/VisHeader.js b/ui/src/data_explorer/components/VisHeader.js index 79638222a3..615608d6a7 100644 --- a/ui/src/data_explorer/components/VisHeader.js +++ b/ui/src/data_explorer/components/VisHeader.js @@ -2,25 +2,29 @@ import React from 'react' import PropTypes from 'prop-types' import classnames from 'classnames' import _ from 'lodash' +import moment from 'moment' import {fetchTimeSeriesAsync} from 'shared/actions/timeSeries' -import {resultsToCSV} from 'src/shared/parsing/resultsToCSV.js' +import {timeSeriesToTableGraph} from 'src/utils/timeSeriesTransformers' +import {dataToCSV} from 'src/shared/parsing/dataToCSV' import download from 'src/external/download.js' import {TEMPLATES} from 'src/shared/constants' -const getCSV = (query, errorThrown) => async () => { +const getDataForCSV = (query, errorThrown) => async () => { try { - const {results} = await fetchTimeSeriesAsync({ + const response = await fetchTimeSeriesAsync({ source: query.host, query, tempVars: TEMPLATES, }) - const {flag, name, CSVString} = resultsToCSV(results) - if (flag === 'no_data') { - errorThrown('no data', 'There are no data to download.') - return - } - download(CSVString, `${name}.csv`, 'text/plain') + const {data} = timeSeriesToTableGraph([{response}]) + const db = _.get(query, ['queryConfig', 'database'], '') + const rp = _.get(query, ['queryConfig', 'retentionPolicy'], '') + const measurement = _.get(query, ['queryConfig', 'measurement'], '') + + const timestring = moment().format('YYYY-MM-DD-HH-mm') + const name = `${db}.${rp}.${measurement}.${timestring}` + download(dataToCSV(data), `${name}.csv`, 'text/plain') } catch (error) { errorThrown(error, 'Unable to download .csv file') console.error(error) @@ -46,7 +50,7 @@ const VisHeader = ({views, view, onToggleView, query, errorThrown}) => ( {query ? (
.csv diff --git a/ui/src/data_explorer/constants/index.js b/ui/src/data_explorer/constants/index.js index c6e9bf0767..85c08d8283 100644 --- a/ui/src/data_explorer/constants/index.js +++ b/ui/src/data_explorer/constants/index.js @@ -15,6 +15,7 @@ export const MINIMUM_HEIGHTS = { queryMaker: 350, visualization: 200, } + export const INITIAL_HEIGHTS = { queryMaker: '66.666%', visualization: '33.334%', diff --git a/ui/src/external/codemirror.js b/ui/src/external/codemirror.js index 3129a886d6..927219b32b 100644 --- a/ui/src/external/codemirror.js +++ b/ui/src/external/codemirror.js @@ -1,13 +1,13 @@ /* eslint-disable */ const CodeMirror = require('codemirror') -CodeMirror.defineSimpleMode = function(name, states) { - CodeMirror.defineMode(name, function(config) { +CodeMirror.defineSimpleMode = function (name, states) { + CodeMirror.defineMode(name, function (config) { return CodeMirror.simpleMode(config, states) }) } -CodeMirror.simpleMode = function(config, states) { +CodeMirror.simpleMode = function (config, states) { ensureState(states, 'start') const states_ = {}, meta = states.meta || {} @@ -53,10 +53,8 @@ CodeMirror.simpleMode = function(config, states) { s.persistentStates = { mode: pers.mode, spec: pers.spec, - state: - pers.state === state.localState - ? s.localState - : CodeMirror.copyState(pers.mode, pers.state), + state: pers.state === state.localState ? + s.localState : CodeMirror.copyState(pers.mode, pers.state), next: s.persistentStates, } } @@ -64,7 +62,10 @@ CodeMirror.simpleMode = function(config, states) { }, token: tokenFunction(states_, config), innerMode(state) { - return state.local && {mode: state.local.mode, state: state.localState} + return state.local && { + mode: state.local.mode, + state: state.localState + } }, indent: indentFunction(states_, meta), } @@ -127,7 +128,7 @@ function Rule(data, states) { } function tokenFunction(states, config) { - return function(stream, state) { + return function (stream, state) { if (state.pending) { const pend = state.pending.shift() if (state.pending.length === 0) { @@ -163,8 +164,8 @@ function tokenFunction(states, config) { if (matches) { if (rule.data.next) { state.state = rule.data.next - } else if (rule.data.push) { - ;(state.stack || (state.stack = [])).push(state.state) + } else if (rule.data.push) {; + (state.stack || (state.stack = [])).push(state.state) state.state = rule.data.push } else if (rule.data.pop && state.stack && state.stack.length) { state.state = state.stack.pop() @@ -187,7 +188,10 @@ function tokenFunction(states, config) { state.pending = [] for (let j = 2; j < matches.length; j++) { if (matches[j]) { - state.pending.push({text: matches[j], token: rule.token[j - 1]}) + state.pending.push({ + text: matches[j], + token: rule.token[j - 1] + }) } } stream.backUp( @@ -238,9 +242,9 @@ function enterLocalMode(config, state, spec, token) { } } } - const mode = pers - ? pers.mode - : spec.mode || CodeMirror.getMode(config, spec.spec) + const mode = pers ? + pers.mode : + spec.mode || CodeMirror.getMode(config, spec.spec) const lState = pers ? pers.state : CodeMirror.startState(mode) if (spec.persistent && !pers) { state.persistentStates = { @@ -269,7 +273,7 @@ function indexOf(val, arr) { } function indentFunction(states, meta) { - return function(state, textAfter, line) { + return function (state, textAfter, line) { if (state.local && state.local.mode.indent) { return state.local.mode.indent(state.localState, textAfter, line) } @@ -309,8 +313,14 @@ CodeMirror.defineSimpleMode('tickscript', { // The start state contains the rules that are intially used start: [ // The regex matches the token, the token property contains the type - {regex: /"(?:[^\\]|\\.)*?(?:"|$)/, token: 'string.double'}, - {regex: /'(?:[^\\]|\\.)*?(?:'|$)/, token: 'string.single'}, + { + regex: /"(?:[^\\]|\\.)*?(?:"|$)/, + token: 'string.double' + }, + { + regex: /'(?:[^\\]|\\.)*?(?:'|$)/, + token: 'string.single' + }, { regex: /(function)(\s+)([a-z$][\w$]*)/, token: ['keyword', null, 'variable-2'], @@ -321,22 +331,47 @@ CodeMirror.defineSimpleMode('tickscript', { regex: /(?:var|return|if|for|while|else|do|this|stream|batch|influxql|lambda)/, token: 'keyword', }, - {regex: /true|false|null|undefined|TRUE|FALSE/, token: 'atom'}, + { + regex: /true|false|null|undefined|TRUE|FALSE/, + token: 'atom' + }, { regex: /0x[a-f\d]+|[-+]?(?:\.\d+|\d+\.?\d*)(?:e[-+]?\d+)?/i, token: 'number', }, - {regex: /\/\/.*/, token: 'comment'}, - {regex: /\/(?:[^\\]|\\.)*?\//, token: 'variable-3'}, + { + regex: /\/\/.*/, + token: 'comment' + }, + { + regex: /\/(?:[^\\]|\\.)*?\//, + token: 'variable-3' + }, // A next property will cause the mode to move to a different state - {regex: /\/\*/, token: 'comment', next: 'comment'}, - {regex: /[-+\/*=<>!]+/, token: 'operator'}, - {regex: /[a-z$][\w$]*/, token: 'variable'}, + { + regex: /\/\*/, + token: 'comment', + next: 'comment' + }, + { + regex: /[-+\/*=<>!]+/, + token: 'operator' + }, + { + regex: /[a-z$][\w$]*/, + token: 'variable' + }, ], // The multi-line comment state. - comment: [ - {regex: /.*?\*\//, token: 'comment', next: 'start'}, - {regex: /.*/, token: 'comment'}, + comment: [{ + regex: /.*?\*\//, + token: 'comment', + next: 'start' + }, + { + regex: /.*/, + token: 'comment' + }, ], // The meta property contains global information about the mode. It // can contain properties like lineComment, which are supported by @@ -347,3 +382,536 @@ CodeMirror.defineSimpleMode('tickscript', { lineComment: '//', }, }) + +// CodeMirror Hints + +var HINT_ELEMENT_CLASS = "CodeMirror-hint"; +var ACTIVE_HINT_ELEMENT_CLASS = "CodeMirror-hint-active"; + +// This is the old interface, kept around for now to stay backwards-compatible. +CodeMirror.showHint = function (cm, getHints, options) { + if (!getHints) return cm.showHint(options); + if (options && options.async) getHints.async = true; + var newOpts = { + hint: getHints + }; + if (options) + for (var prop in options) newOpts[prop] = options[prop]; + return cm.showHint(newOpts); +}; + +CodeMirror.defineExtension("showHint", function (options) { + options = parseOptions(this, this.getCursor("start"), options); + var selections = this.listSelections() + if (selections.length > 1) return; + // By default, don't allow completion when something is selected. + // A hint function can have a `supportsSelection` property to + // indicate that it can handle selections. + if (this.somethingSelected()) { + if (!options.hint.supportsSelection) return; + // Don't try with cross-line selections + for (var i = 0; i < selections.length; i++) + if (selections[i].head.line != selections[i].anchor.line) return; + } + + if (this.state.completionActive) this.state.completionActive.close(); + var completion = this.state.completionActive = new Completion(this, options); + if (!completion.options.hint) return; + + CodeMirror.signal(this, "startCompletion", this); + completion.update(true); +}); + +function Completion(cm, options) { + this.cm = cm; + this.options = options; + this.widget = null; + this.debounce = 0; + this.tick = 0; + this.startPos = this.cm.getCursor("start"); + this.startLen = this.cm.getLine(this.startPos.line).length - this.cm.getSelection().length; + + var self = this; + cm.on("cursorActivity", this.activityFunc = function () { + self.cursorActivity(); + }); +} + +var requestAnimationFrame = window.requestAnimationFrame || function (fn) { + return setTimeout(fn, 1000 / 60); +}; +var cancelAnimationFrame = window.cancelAnimationFrame || clearTimeout; + +Completion.prototype = { + close: function () { + if (!this.active()) return; + this.cm.state.completionActive = null; + this.tick = null; + this.cm.off("cursorActivity", this.activityFunc); + + if (this.widget && this.data) CodeMirror.signal(this.data, "close"); + if (this.widget) this.widget.close(); + CodeMirror.signal(this.cm, "endCompletion", this.cm); + }, + + active: function () { + return this.cm.state.completionActive == this; + }, + + pick: function (data, i) { + var completion = data.list[i]; + if (completion.hint) completion.hint(this.cm, data, completion); + else this.cm.replaceRange(getText(completion), completion.from || data.from, + completion.to || data.to, "complete"); + CodeMirror.signal(data, "pick", completion); + this.close(); + }, + + cursorActivity: function () { + if (this.debounce) { + cancelAnimationFrame(this.debounce); + this.debounce = 0; + } + + var pos = this.cm.getCursor(), + line = this.cm.getLine(pos.line); + if (pos.line != this.startPos.line || line.length - pos.ch != this.startLen - this.startPos.ch || + pos.ch < this.startPos.ch || this.cm.somethingSelected() || + (pos.ch && this.options.closeCharacters.test(line.charAt(pos.ch - 1)))) { + this.close(); + } else { + var self = this; + this.debounce = requestAnimationFrame(function () { + self.update(); + }); + if (this.widget) this.widget.disable(); + } + }, + + update: function (first) { + if (this.tick == null) return + var self = this, + myTick = ++this.tick + fetchHints(this.options.hint, this.cm, this.options, function (data) { + if (self.tick == myTick) self.finishUpdate(data, first) + }) + }, + + finishUpdate: function (data, first) { + if (this.data) CodeMirror.signal(this.data, "update"); + + var picked = (this.widget && this.widget.picked) || (first && this.options.completeSingle); + if (this.widget) this.widget.close(); + + this.data = data; + + if (data && data.list.length) { + if (picked && data.list.length == 1) { + this.pick(data, 0); + } else { + this.widget = new Widget(this, data); + CodeMirror.signal(data, "shown"); + } + } + } +}; + +function parseOptions(cm, pos, options) { + var editor = cm.options.hintOptions; + var out = {}; + for (var prop in defaultOptions) out[prop] = defaultOptions[prop]; + if (editor) + for (var prop in editor) + if (editor[prop] !== undefined) out[prop] = editor[prop]; + if (options) + for (var prop in options) + if (options[prop] !== undefined) out[prop] = options[prop]; + if (out.hint.resolve) out.hint = out.hint.resolve(cm, pos) + return out; +} + +function getText(completion) { + if (typeof completion == "string") return completion; + else return completion.text; +} + +function buildKeyMap(completion, handle) { + var baseMap = { + Up: function () { + handle.moveFocus(-1); + }, + Down: function () { + handle.moveFocus(1); + }, + PageUp: function () { + handle.moveFocus(-handle.menuSize() + 1, true); + }, + PageDown: function () { + handle.moveFocus(handle.menuSize() - 1, true); + }, + Home: function () { + handle.setFocus(0); + }, + End: function () { + handle.setFocus(handle.length - 1); + }, + Enter: handle.pick, + Tab: handle.pick, + Esc: handle.close + }; + var custom = completion.options.customKeys; + var ourMap = custom ? {} : baseMap; + + function addBinding(key, val) { + var bound; + if (typeof val != "string") + bound = function (cm) { + return val(cm, handle); + }; + // This mechanism is deprecated + else if (baseMap.hasOwnProperty(val)) + bound = baseMap[val]; + else + bound = val; + ourMap[key] = bound; + } + if (custom) + for (var key in custom) + if (custom.hasOwnProperty(key)) + addBinding(key, custom[key]); + var extra = completion.options.extraKeys; + if (extra) + for (var key in extra) + if (extra.hasOwnProperty(key)) + addBinding(key, extra[key]); + return ourMap; +} + +function getHintElement(hintsElement, el) { + while (el && el != hintsElement) { + if (el.nodeName.toUpperCase() === "LI" && el.parentNode == hintsElement) return el; + el = el.parentNode; + } +} + +function Widget(completion, data) { + this.completion = completion; + this.data = data; + this.picked = false; + var widget = this, + cm = completion.cm; + + var hints = this.hints = document.createElement("ul"); + hints.className = "CodeMirror-hints"; + this.selectedHint = data.selectedHint || 0; + + var completions = data.list; + for (var i = 0; i < completions.length; ++i) { + var elt = hints.appendChild(document.createElement("li")), + cur = completions[i]; + var className = HINT_ELEMENT_CLASS + (i != this.selectedHint ? "" : " " + ACTIVE_HINT_ELEMENT_CLASS); + if (cur.className != null) className = cur.className + " " + className; + elt.className = className; + if (cur.render) cur.render(elt, data, cur); + else elt.appendChild(document.createTextNode(cur.displayText || getText(cur))); + elt.hintId = i; + } + + var pos = cm.cursorCoords(completion.options.alignWithWord ? data.from : null); + var left = pos.left, + top = pos.bottom, + below = true; + hints.style.left = left + "px"; + hints.style.top = top + "px"; + // If we're at the edge of the screen, then we want the menu to appear on the left of the cursor. + var winW = window.innerWidth || Math.max(document.body.offsetWidth, document.documentElement.offsetWidth); + var winH = window.innerHeight || Math.max(document.body.offsetHeight, document.documentElement.offsetHeight); + (completion.options.container || document.body).appendChild(hints); + var box = hints.getBoundingClientRect(), + overlapY = box.bottom - winH; + var scrolls = hints.scrollHeight > hints.clientHeight + 1 + var startScroll = cm.getScrollInfo(); + + if (overlapY > 0) { + var height = box.bottom - box.top, + curTop = pos.top - (pos.bottom - box.top); + if (curTop - height > 0) { // Fits above cursor + hints.style.top = (top = pos.top - height) + "px"; + below = false; + } else if (height > winH) { + hints.style.height = (winH - 5) + "px"; + hints.style.top = (top = pos.bottom - box.top) + "px"; + var cursor = cm.getCursor(); + if (data.from.ch != cursor.ch) { + pos = cm.cursorCoords(cursor); + hints.style.left = (left = pos.left) + "px"; + box = hints.getBoundingClientRect(); + } + } + } + var overlapX = box.right - winW; + if (overlapX > 0) { + if (box.right - box.left > winW) { + hints.style.width = (winW - 5) + "px"; + overlapX -= (box.right - box.left) - winW; + } + hints.style.left = (left = pos.left - overlapX) + "px"; + } + if (scrolls) + for (var node = hints.firstChild; node; node = node.nextSibling) + node.style.paddingRight = cm.display.nativeBarWidth + "px" + + cm.addKeyMap(this.keyMap = buildKeyMap(completion, { + moveFocus: function (n, avoidWrap) { + widget.changeActive(widget.selectedHint + n, avoidWrap); + }, + setFocus: function (n) { + widget.changeActive(n); + }, + menuSize: function () { + return widget.screenAmount(); + }, + length: completions.length, + close: function () { + completion.close(); + }, + pick: function () { + widget.pick(); + }, + data: data + })); + + if (completion.options.closeOnUnfocus) { + var closingOnBlur; + cm.on("blur", this.onBlur = function () { + closingOnBlur = setTimeout(function () { + completion.close(); + }, 100); + }); + cm.on("focus", this.onFocus = function () { + clearTimeout(closingOnBlur); + }); + } + + cm.on("scroll", this.onScroll = function () { + var curScroll = cm.getScrollInfo(), + editor = cm.getWrapperElement().getBoundingClientRect(); + var newTop = top + startScroll.top - curScroll.top; + var point = newTop - (window.pageYOffset || (document.documentElement || document.body).scrollTop); + if (!below) point += hints.offsetHeight; + if (point <= editor.top || point >= editor.bottom) return completion.close(); + hints.style.top = newTop + "px"; + hints.style.left = (left + startScroll.left - curScroll.left) + "px"; + }); + + CodeMirror.on(hints, "dblclick", function (e) { + var t = getHintElement(hints, e.target || e.srcElement); + if (t && t.hintId != null) { + widget.changeActive(t.hintId); + widget.pick(); + } + }); + + CodeMirror.on(hints, "click", function (e) { + var t = getHintElement(hints, e.target || e.srcElement); + if (t && t.hintId != null) { + widget.changeActive(t.hintId); + if (completion.options.completeOnSingleClick) widget.pick(); + } + }); + + CodeMirror.on(hints, "mousedown", function () { + setTimeout(function () { + cm.focus(); + }, 20); + }); + + CodeMirror.signal(data, "select", completions[this.selectedHint], hints.childNodes[this.selectedHint]); + return true; +} + +Widget.prototype = { + close: function () { + if (this.completion.widget != this) return; + this.completion.widget = null; + this.hints.parentNode.removeChild(this.hints); + this.completion.cm.removeKeyMap(this.keyMap); + + var cm = this.completion.cm; + if (this.completion.options.closeOnUnfocus) { + cm.off("blur", this.onBlur); + cm.off("focus", this.onFocus); + } + cm.off("scroll", this.onScroll); + }, + + disable: function () { + this.completion.cm.removeKeyMap(this.keyMap); + var widget = this; + this.keyMap = { + Enter: function () { + widget.picked = true; + } + }; + this.completion.cm.addKeyMap(this.keyMap); + }, + + pick: function () { + this.completion.pick(this.data, this.selectedHint); + }, + + changeActive: function (i, avoidWrap) { + if (i >= this.data.list.length) + i = avoidWrap ? this.data.list.length - 1 : 0; + else if (i < 0) + i = avoidWrap ? 0 : this.data.list.length - 1; + if (this.selectedHint == i) return; + var node = this.hints.childNodes[this.selectedHint]; + node.className = node.className.replace(" " + ACTIVE_HINT_ELEMENT_CLASS, ""); + node = this.hints.childNodes[this.selectedHint = i]; + node.className += " " + ACTIVE_HINT_ELEMENT_CLASS; + if (node.offsetTop < this.hints.scrollTop) + this.hints.scrollTop = node.offsetTop - 3; + else if (node.offsetTop + node.offsetHeight > this.hints.scrollTop + this.hints.clientHeight) + this.hints.scrollTop = node.offsetTop + node.offsetHeight - this.hints.clientHeight + 3; + CodeMirror.signal(this.data, "select", this.data.list[this.selectedHint], node); + }, + + screenAmount: function () { + return Math.floor(this.hints.clientHeight / this.hints.firstChild.offsetHeight) || 1; + } +}; + +function applicableHelpers(cm, helpers) { + if (!cm.somethingSelected()) return helpers + var result = [] + for (var i = 0; i < helpers.length; i++) + if (helpers[i].supportsSelection) result.push(helpers[i]) + return result +} + +function fetchHints(hint, cm, options, callback) { + if (hint.async) { + hint(cm, callback, options) + } else { + var result = hint(cm, options) + if (result && result.then) result.then(callback) + else callback(result) + } +} + +function resolveAutoHints(cm, pos) { + var helpers = cm.getHelpers(pos, "hint"), + words + if (helpers.length) { + var resolved = function (cm, callback, options) { + var app = applicableHelpers(cm, helpers); + + function run(i) { + if (i == app.length) return callback(null) + fetchHints(app[i], cm, options, function (result) { + if (result && result.list.length > 0) callback(result) + else run(i + 1) + }) + } + run(0) + } + resolved.async = true + resolved.supportsSelection = true + return resolved + } else if (words = cm.getHelper(cm.getCursor(), "hintWords")) { + return function (cm) { + return CodeMirror.hint.fromList(cm, { + words: words + }) + } + } else if (CodeMirror.hint.anyword) { + return function (cm, options) { + return CodeMirror.hint.anyword(cm, options) + } + } else { + return function () {} + } +} + +CodeMirror.registerHelper("hint", "auto", { + resolve: resolveAutoHints +}); + +CodeMirror.registerHelper("hint", "fromList", function (cm, options) { + var cur = cm.getCursor(), + token = cm.getTokenAt(cur) + var term, from = CodeMirror.Pos(cur.line, token.start), + to = cur + if (token.start < cur.ch && /\w/.test(token.string.charAt(cur.ch - token.start - 1))) { + term = token.string.substr(0, cur.ch - token.start) + } else { + term = "" + from = cur + } + var found = []; + for (var i = 0; i < options.words.length; i++) { + var word = options.words[i]; + if (word.slice(0, term.length) == term) + found.push(word); + } + + if (found.length) return { + list: found, + from: from, + to: to + }; +}); + +CodeMirror.commands.autocomplete = CodeMirror.showHint; + +var defaultOptions = { + hint: CodeMirror.hint.auto, + completeSingle: true, + alignWithWord: true, + closeCharacters: /[\s()\[\]{};:>,]/, + closeOnUnfocus: true, + completeOnSingleClick: true, + container: null, + customKeys: null, + extraKeys: null +}; + +CodeMirror.defineOption("hintOptions", null); +var WORD = /[\w$]+/, + RANGE = 500; + +CodeMirror.registerHelper("hint", "anyword", function (editor, options) { + var word = options && options.word || WORD; + var range = options && options.range || RANGE; + var cur = editor.getCursor(), + curLine = editor.getLine(cur.line); + var end = cur.ch, + start = end; + while (start && word.test(curLine.charAt(start - 1))) --start; + var curWord = start != end && curLine.slice(start, end); + + var list = options && options.list || [], + seen = {}; + var re = new RegExp(word.source, "g"); + for (var dir = -1; dir <= 1; dir += 2) { + var line = cur.line, + endLine = Math.min(Math.max(line + dir * range, editor.firstLine()), editor.lastLine()) + dir; + for (; line != endLine; line += dir) { + var text = editor.getLine(line), + m; + while (m = re.exec(text)) { + if (line == cur.line && m[0] === curWord) continue; + if ((!curWord || m[0].lastIndexOf(curWord, 0) == 0) && !Object.prototype.hasOwnProperty.call(seen, m[0])) { + seen[m[0]] = true; + list.push(m[0]); + } + } + } + } + return { + list: list, + from: CodeMirror.Pos(cur.line, start), + to: CodeMirror.Pos(cur.line, end) + }; +}); \ No newline at end of file diff --git a/ui/src/ifql/apis/index.ts b/ui/src/ifql/apis/index.ts index 5b84069672..36467dcaca 100644 --- a/ui/src/ifql/apis/index.ts +++ b/ui/src/ifql/apis/index.ts @@ -46,3 +46,25 @@ export const getDatabases = async () => { throw error } } + +export const getTags = async () => { + try { + const response = {data: {tags: ['tk1', 'tk2', 'tk3']}} + const {data} = await Promise.resolve(response) + return data.tags + } catch (error) { + console.error('Could not get tags', error) + throw error + } +} + +export const getTagValues = async () => { + try { + const response = {data: {values: ['tv1', 'tv2', 'tv3']}} + const {data} = await Promise.resolve(response) + return data.values + } catch (error) { + console.error('Could not get tag values', error) + throw error + } +} diff --git a/ui/src/ifql/components/BodyBuilder.tsx b/ui/src/ifql/components/BodyBuilder.tsx index e81855fa64..91d6977b3a 100644 --- a/ui/src/ifql/components/BodyBuilder.tsx +++ b/ui/src/ifql/components/BodyBuilder.tsx @@ -2,6 +2,8 @@ import React, {PureComponent} from 'react' import _ from 'lodash' import ExpressionNode from 'src/ifql/components/ExpressionNode' +import VariableName from 'src/ifql/components/VariableName' +import FuncSelector from 'src/ifql/components/FuncSelector' import {FlatBody, Suggestion} from 'src/types/ifql' @@ -21,8 +23,8 @@ class BodyBuilder extends PureComponent { return b.declarations.map(d => { if (d.funcs) { return ( -
-
{d.name} =
+
+ { } return ( -
- {b.source} +
+
) }) } return ( - +
+ + +
) }) - return _.flatten(bodybuilder) + return ( +
+ {_.flatten(bodybuilder)} +
+ +
+
+ ) + } + + private get newDeclarationFuncs(): string[] { + // 'JOIN' only available if there are at least 2 named declarations + return ['from', 'join', 'variable'] + } + + private createNewDeclaration = (bodyID, name, declarationID) => { + // Returning a string here so linter stops yelling + // TODO: write a real function + + return `${bodyID} / ${name} / ${declarationID}` } private get funcNames() { diff --git a/ui/src/ifql/components/DatabaseList.tsx b/ui/src/ifql/components/DatabaseList.tsx new file mode 100644 index 0000000000..71b15db130 --- /dev/null +++ b/ui/src/ifql/components/DatabaseList.tsx @@ -0,0 +1,61 @@ +import React, {PureComponent} from 'react' +import PropTypes from 'prop-types' + +import DatabaseListItem from 'src/ifql/components/DatabaseListItem' + +import {showDatabases} from 'src/shared/apis/metaQuery' +import showDatabasesParser from 'src/shared/parsing/showDatabases' + +import {ErrorHandling} from 'src/shared/decorators/errors' + +interface DatabaseListState { + databases: string[] + measurement: string + db: string +} + +const {shape} = PropTypes + +@ErrorHandling +class DatabaseList extends PureComponent<{}, DatabaseListState> { + public static contextTypes = { + source: shape({ + links: shape({}).isRequired, + }).isRequired, + } + + constructor(props) { + super(props) + this.state = { + databases: [], + measurement: '', + db: '', + } + } + + public componentDidMount() { + this.getDatabases() + } + + public async getDatabases() { + const {source} = this.context + + try { + const {data} = await showDatabases(source.links.proxy) + const {databases} = showDatabasesParser(data) + const sorted = databases.sort() + + this.setState({databases: sorted}) + } catch (err) { + console.error(err) + } + } + + public render() { + return this.state.databases.map(db => { + return + }) + } +} + +export default DatabaseList diff --git a/ui/src/ifql/components/DatabaseListItem.tsx b/ui/src/ifql/components/DatabaseListItem.tsx new file mode 100644 index 0000000000..7c81291533 --- /dev/null +++ b/ui/src/ifql/components/DatabaseListItem.tsx @@ -0,0 +1,49 @@ +import React, {PureComponent} from 'react' + +import classnames from 'classnames' + +import TagList from 'src/ifql/components/TagList' + +interface Props { + db: string +} + +interface State { + isOpen: boolean +} + +class DatabaseListItem extends PureComponent { + constructor(props) { + super(props) + this.state = { + isOpen: false, + } + } + + public render() { + const {db} = this.props + + return ( +
+
+
+ {db} + Bucket +
+ {this.state.isOpen && } +
+ ) + } + + private get className(): string { + return classnames('ifql-schema-tree', { + expanded: this.state.isOpen, + }) + } + + private handleChooseDatabase = () => { + this.setState({isOpen: !this.state.isOpen}) + } +} + +export default DatabaseListItem diff --git a/ui/src/ifql/components/ExpressionNode.tsx b/ui/src/ifql/components/ExpressionNode.tsx index 8ef81749bb..893b9c014b 100644 --- a/ui/src/ifql/components/ExpressionNode.tsx +++ b/ui/src/ifql/components/ExpressionNode.tsx @@ -21,7 +21,7 @@ class ExpressionNode extends PureComponent { {({onDeleteFuncNode, onAddNode, onChangeArg, onGenerateScript}) => { return ( -
+ <> {funcs.map(func => ( { onAddNode={onAddNode} declarationID={declarationID} /> -
+ ) }}
diff --git a/ui/src/ifql/components/From.tsx b/ui/src/ifql/components/From.tsx index 4ecdf9b73b..4b4f038a6f 100644 --- a/ui/src/ifql/components/From.tsx +++ b/ui/src/ifql/components/From.tsx @@ -41,12 +41,13 @@ class From extends PureComponent { public render() { const {value, argKey} = this.props + return ( -
- +
+ { // TODO: make separate function component return (
- {argKey} : {value} + +
{value}
) } @@ -96,14 +97,16 @@ class FuncArg extends PureComponent { // TODO: handle nil type return (
- {argKey} : {value} + +
{value}
) } default: { return (
- {argKey} : {value} + +
{value}
) } diff --git a/ui/src/ifql/components/FuncArgBool.tsx b/ui/src/ifql/components/FuncArgBool.tsx index e87b0f8dcd..792f36c1cb 100644 --- a/ui/src/ifql/components/FuncArgBool.tsx +++ b/ui/src/ifql/components/FuncArgBool.tsx @@ -16,9 +16,11 @@ interface Props { class FuncArgBool extends PureComponent { public render() { return ( -
- {this.props.argKey}: - +
+ +
+ +
) } diff --git a/ui/src/ifql/components/FuncArgInput.tsx b/ui/src/ifql/components/FuncArgInput.tsx index 644e8f316e..29fff6a4a3 100644 --- a/ui/src/ifql/components/FuncArgInput.tsx +++ b/ui/src/ifql/components/FuncArgInput.tsx @@ -17,20 +17,25 @@ interface Props { class FuncArgInput extends PureComponent { public render() { const {argKey, value, type} = this.props + return ( -
- - +
+ +
+ +
) } diff --git a/ui/src/ifql/components/FuncArgs.tsx b/ui/src/ifql/components/FuncArgs.tsx index 10c75d4cf1..6f68dea86c 100644 --- a/ui/src/ifql/components/FuncArgs.tsx +++ b/ui/src/ifql/components/FuncArgs.tsx @@ -10,6 +10,7 @@ interface Props { onChangeArg: OnChangeArg declarationID: string onGenerateScript: () => void + onDeleteFunc: () => void } @ErrorHandling @@ -19,12 +20,13 @@ export default class FuncArgs extends PureComponent { func, bodyID, onChangeArg, + onDeleteFunc, declarationID, onGenerateScript, } = this.props return ( -
+
{func.args.map(({key, value, type}) => { return ( { /> ) })} +
+ Delete +
) } diff --git a/ui/src/ifql/components/FuncArgsPreview.tsx b/ui/src/ifql/components/FuncArgsPreview.tsx new file mode 100644 index 0000000000..1425c8b064 --- /dev/null +++ b/ui/src/ifql/components/FuncArgsPreview.tsx @@ -0,0 +1,66 @@ +import React, {PureComponent} from 'react' +import {Arg} from 'src/types/ifql' +import uuid from 'uuid' + +interface Props { + args: Arg[] +} + +export default class FuncArgsPreview extends PureComponent { + public render() { + return
{this.summarizeArguments}
+ } + + private get summarizeArguments(): JSX.Element | JSX.Element[] { + const {args} = this.props + + if (!args) { + return + } + + return this.colorizedArguments + } + + private get colorizedArguments(): JSX.Element | JSX.Element[] { + const {args} = this.props + + return args.map((arg, i): JSX.Element => { + if (!arg.value) { + return + } + + const separator = i === 0 ? null : ', ' + + return ( + + {separator} + {arg.key}: {this.colorArgType(`${arg.value}`, arg.type)} + + ) + }) + } + + private colorArgType = (argument: string, type: string): JSX.Element => { + switch (type) { + case 'time': + case 'number': + case 'period': + case 'duration': + case 'array': { + return {argument} + } + case 'bool': { + return {argument} + } + case 'string': { + return "{argument}" + } + case 'invalid': { + return {argument} + } + default: { + return {argument} + } + } + } +} diff --git a/ui/src/ifql/components/FuncList.tsx b/ui/src/ifql/components/FuncList.tsx index 902ee9adad..ea64c22972 100644 --- a/ui/src/ifql/components/FuncList.tsx +++ b/ui/src/ifql/components/FuncList.tsx @@ -48,7 +48,7 @@ const FuncList: SFC = ({ /> )) ) : ( -
No results
+
No matches
)} diff --git a/ui/src/ifql/components/FuncNode.tsx b/ui/src/ifql/components/FuncNode.tsx index 343f6bd1bf..18341e5153 100644 --- a/ui/src/ifql/components/FuncNode.tsx +++ b/ui/src/ifql/components/FuncNode.tsx @@ -1,5 +1,7 @@ import React, {PureComponent, MouseEvent} from 'react' + import FuncArgs from 'src/ifql/components/FuncArgs' +import FuncArgsPreview from 'src/ifql/components/FuncArgsPreview' import {OnDeleteFuncNode, OnChangeArg, Func} from 'src/types/ifql' import {ErrorHandling} from 'src/shared/decorators/errors' @@ -13,7 +15,7 @@ interface Props { } interface State { - isOpen: boolean + isExpanded: boolean } @ErrorHandling @@ -25,37 +27,39 @@ export default class FuncNode extends PureComponent { constructor(props) { super(props) this.state = { - isOpen: true, + isExpanded: false, } } public render() { const { func, + func: {args}, bodyID, onChangeArg, declarationID, onGenerateScript, } = this.props - const {isOpen} = this.state + const {isExpanded} = this.state return ( -
-
-
{func.name}
-
- {isOpen && ( +
+
{func.name}
+ + {isExpanded && ( )} -
- -
) } @@ -66,10 +70,15 @@ export default class FuncNode extends PureComponent { this.props.onDelete({funcID: func.id, bodyID, declarationID}) } - private handleClick = (e: MouseEvent): void => { + private handleMouseEnter = (e: MouseEvent): void => { e.stopPropagation() - const {isOpen} = this.state - this.setState({isOpen: !isOpen}) + this.setState({isExpanded: true}) + } + + private handleMouseLeave = (e: MouseEvent): void => { + e.stopPropagation() + + this.setState({isExpanded: false}) } } diff --git a/ui/src/ifql/components/FuncSelector.tsx b/ui/src/ifql/components/FuncSelector.tsx index 6e2fdae10e..c2fc40c579 100644 --- a/ui/src/ifql/components/FuncSelector.tsx +++ b/ui/src/ifql/components/FuncSelector.tsx @@ -1,5 +1,6 @@ import React, {PureComponent, ChangeEvent, KeyboardEvent} from 'react' import _ from 'lodash' +import classnames from 'classnames' import {ClickOutside} from 'src/shared/components/ClickOutside' import FuncList from 'src/ifql/components/FuncList' @@ -17,10 +18,15 @@ interface Props { bodyID: string declarationID: string onAddNode: OnAddNode + connectorVisible?: boolean } @ErrorHandling export class FuncSelector extends PureComponent { + public static defaultProps: Partial = { + connectorVisible: true, + } + constructor(props) { super(props) @@ -33,10 +39,12 @@ export class FuncSelector extends PureComponent { public render() { const {isOpen, inputText, selectedFunc} = this.state + const {connectorVisible} = this.props return ( -
+
+ {connectorVisible &&
} {isOpen ? ( { onClick={this.handleOpenList} tabIndex={0} > - 𝑓⟮𝑥⟯ + )}
@@ -61,6 +69,12 @@ export class FuncSelector extends PureComponent { ) } + private get className(): string { + const {isOpen} = this.state + + return classnames('ifql-func--selector', {open: isOpen}) + } + private handleCloseList = () => { this.setState({isOpen: false, selectedFunc: ''}) } diff --git a/ui/src/ifql/components/SchemaExplorer.tsx b/ui/src/ifql/components/SchemaExplorer.tsx new file mode 100644 index 0000000000..7524213e90 --- /dev/null +++ b/ui/src/ifql/components/SchemaExplorer.tsx @@ -0,0 +1,32 @@ +import React, {PureComponent} from 'react' +import DatabaseList from 'src/ifql/components/DatabaseList' + +class SchemaExplorer extends PureComponent { + public render() { + return ( +
+
+
+ +
+ +
+ +
+ ) + } +} + +export default SchemaExplorer diff --git a/ui/src/ifql/components/TagList.tsx b/ui/src/ifql/components/TagList.tsx new file mode 100644 index 0000000000..abf6c3079c --- /dev/null +++ b/ui/src/ifql/components/TagList.tsx @@ -0,0 +1,65 @@ +import PropTypes from 'prop-types' +import React, {PureComponent} from 'react' + +import _ from 'lodash' + +import TagListItem from 'src/ifql/components/TagListItem' + +import {getTags, getTagValues} from 'src/ifql/apis' +import {ErrorHandling} from 'src/shared/decorators/errors' + +const {shape} = PropTypes + +interface Props { + db: string +} + +interface State { + tags: {} + selectedTag: string +} + +@ErrorHandling +class TagList extends PureComponent { + public static contextTypes = { + source: shape({ + links: shape({}).isRequired, + }).isRequired, + } + + constructor(props) { + super(props) + this.state = { + tags: {}, + selectedTag: '', + } + } + + public componentDidMount() { + const {db} = this.props + if (!db) { + return + } + + this.getTags() + } + + public async getTags() { + const keys = await getTags() + const values = await getTagValues() + + const tags = keys.reduce((acc, k) => { + return {...acc, [k]: values} + }, {}) + + this.setState({tags}) + } + + public render() { + return _.map(this.state.tags, (tagValues: string[], tagKey: string) => ( + + )) + } +} + +export default TagList diff --git a/ui/src/ifql/components/TagListItem.tsx b/ui/src/ifql/components/TagListItem.tsx new file mode 100644 index 0000000000..2b224bc474 --- /dev/null +++ b/ui/src/ifql/components/TagListItem.tsx @@ -0,0 +1,69 @@ +import classnames from 'classnames' +import React, {PureComponent, MouseEvent} from 'react' +import {ErrorHandling} from 'src/shared/decorators/errors' + +interface Props { + tagKey: string + tagValues: string[] +} + +interface State { + isOpen: boolean +} + +@ErrorHandling +class TagListItem extends PureComponent { + constructor(props) { + super(props) + this.state = { + isOpen: false, + } + } + + public render() { + const {isOpen} = this.state + + return ( +
+
+
+ {this.tagItemLabel} + Tag Key +
+ {isOpen && this.renderTagValues} +
+ ) + } + + private handleClick = (e: MouseEvent): void => { + e.stopPropagation() + this.setState({isOpen: !this.state.isOpen}) + } + + private get tagItemLabel(): string { + const {tagKey} = this.props + return `${tagKey}` + } + + private get renderTagValues(): JSX.Element[] | JSX.Element { + const {tagValues} = this.props + if (!tagValues || !tagValues.length) { + return
No tag values
+ } + + return tagValues.map(v => { + return ( +
+ {v} +
+ ) + }) + } + + private get className(): string { + const {isOpen} = this.state + return classnames('ifql-schema-tree ifql-tree-node', {expanded: isOpen}) + } +} + +export default TagListItem diff --git a/ui/src/ifql/components/TimeMachine.tsx b/ui/src/ifql/components/TimeMachine.tsx index 716c09994b..e26961f38c 100644 --- a/ui/src/ifql/components/TimeMachine.tsx +++ b/ui/src/ifql/components/TimeMachine.tsx @@ -1,20 +1,17 @@ import React, {PureComponent} from 'react' +import SchemaExplorer from 'src/ifql/components/SchemaExplorer' import BodyBuilder from 'src/ifql/components/BodyBuilder' import TimeMachineEditor from 'src/ifql/components/TimeMachineEditor' - -import { - Suggestion, - OnChangeScript, - OnSubmitScript, - FlatBody, -} from 'src/types/ifql' +import TimeMachineVis from 'src/ifql/components/TimeMachineVis' +import Threesizer from 'src/shared/components/Threesizer' +import {Suggestion, OnChangeScript, FlatBody} from 'src/types/ifql' import {ErrorHandling} from 'src/shared/decorators/errors' +import {HANDLE_VERTICAL, HANDLE_HORIZONTAL} from 'src/shared/constants/index' interface Props { script: string suggestions: Suggestion[] body: Body[] - onSubmitScript: OnSubmitScript onChangeScript: OnChangeScript } @@ -25,27 +22,52 @@ interface Body extends FlatBody { @ErrorHandling class TimeMachine extends PureComponent { public render() { - const { - body, - script, - onChangeScript, - onSubmitScript, - suggestions, - } = this.props - return ( -
- -
- -
-
+ ) } + + private get mainSplit() { + return [ + { + handleDisplay: 'none', + render: () => ( + + ), + }, + { + handlePixels: 8, + render: () => , + }, + ] + } + + private get divisions() { + const {body, suggestions, script, onChangeScript} = this.props + return [ + { + name: 'Script', + render: () => ( + + ), + }, + { + name: 'Build', + render: () => , + }, + { + name: 'Explore', + render: () => , + }, + ] + } } export default TimeMachine diff --git a/ui/src/ifql/components/TimeMachineEditor.tsx b/ui/src/ifql/components/TimeMachineEditor.tsx index e448f3e49f..e2fcd7d903 100644 --- a/ui/src/ifql/components/TimeMachineEditor.tsx +++ b/ui/src/ifql/components/TimeMachineEditor.tsx @@ -3,12 +3,16 @@ import {Controlled as CodeMirror, IInstance} from 'react-codemirror2' import {EditorChange} from 'codemirror' import 'src/external/codemirror' import {ErrorHandling} from 'src/shared/decorators/errors' -import {OnSubmitScript, OnChangeScript} from 'src/types/ifql' +import {OnChangeScript} from 'src/types/ifql' +import {editor} from 'src/ifql/constants' interface Props { script: string onChangeScript: OnChangeScript - onSubmitScript: OnSubmitScript +} + +interface EditorInstance extends IInstance { + showHint: (options?: any) => void } @ErrorHandling @@ -25,30 +29,35 @@ class TimeMachineEditor extends PureComponent { theme: 'material', tabIndex: 1, readonly: false, + extraKeys: {'Ctrl-Space': 'autocomplete'}, + completeSingle: false, } return ( -
-
- -
- +
+
) } + private handleKeyUp = (instance: EditorInstance, e: KeyboardEvent) => { + const {key} = e + + if (editor.EXCLUDED_KEYS.includes(key)) { + return + } + + instance.showHint({completeSingle: false}) + } + private onTouchStart = () => {} private updateCode = ( diff --git a/ui/src/ifql/components/TimeMachineVis.tsx b/ui/src/ifql/components/TimeMachineVis.tsx new file mode 100644 index 0000000000..53214b72a0 --- /dev/null +++ b/ui/src/ifql/components/TimeMachineVis.tsx @@ -0,0 +1,14 @@ +import React, {SFC} from 'react' + +interface Props { + blob: string +} +const TimeMachineVis: SFC = ({blob}) => ( +
+
+
{blob}
+
+
+) + +export default TimeMachineVis diff --git a/ui/src/ifql/components/VariableName.tsx b/ui/src/ifql/components/VariableName.tsx new file mode 100644 index 0000000000..cbb9194097 --- /dev/null +++ b/ui/src/ifql/components/VariableName.tsx @@ -0,0 +1,126 @@ +import React, {PureComponent, MouseEvent} from 'react' + +interface Props { + name?: string +} + +interface State { + isExpanded: boolean +} + +export default class VariableName extends PureComponent { + public static defaultProps: Partial = { + name: '', + } + + constructor(props) { + super(props) + + this.state = { + isExpanded: false, + } + } + + public render() { + const {isExpanded} = this.state + + return ( +
+ {this.nameElement} + {isExpanded && this.renderTooltip} +
+ ) + } + + private get renderTooltip(): JSX.Element { + const {name} = this.props + + if (name.includes('=')) { + const split = name.split('=') + const varName = split[0].substring(0, split[0].length - 1) + const varValue = split[1].substring(1) + + return ( +
+ + = + +
+ ) + } + + return ( +
+ +
+ ) + } + + private handleMouseEnter = (e: MouseEvent): void => { + e.stopPropagation() + + this.setState({isExpanded: true}) + } + + private handleMouseLeave = (e: MouseEvent): void => { + e.stopPropagation() + + this.setState({isExpanded: false}) + } + + private get nameElement(): JSX.Element { + const {name} = this.props + + if (!name) { + return Untitled + } + + if (name.includes('=')) { + return this.colorizeSyntax + } + + return {name} + } + + private get colorizeSyntax(): JSX.Element { + const {name} = this.props + const split = name.split('=') + const varName = split[0].substring(0, split[0].length - 1) + const varValue = split[1].substring(1) + + const valueIsString = varValue.endsWith('"') + + return ( + <> + {varName} + {' = '} + + {varValue} + + + ) + } +} diff --git a/ui/src/ifql/constants/ast.ts b/ui/src/ifql/constants/ast.ts new file mode 100644 index 0000000000..e542d8b432 --- /dev/null +++ b/ui/src/ifql/constants/ast.ts @@ -0,0 +1,155 @@ +export const ast = { + type: 'File', + start: 0, + end: 22, + loc: { + start: { + line: 1, + column: 0, + }, + end: { + line: 1, + column: 22, + }, + }, + program: { + type: 'Program', + start: 0, + end: 22, + loc: { + start: { + line: 1, + column: 0, + }, + end: { + line: 1, + column: 22, + }, + }, + sourceType: 'module', + body: [ + { + type: 'ExpressionStatement', + start: 0, + end: 22, + loc: { + start: { + line: 1, + column: 0, + }, + end: { + line: 1, + column: 22, + }, + }, + expression: { + type: 'CallExpression', + start: 0, + end: 22, + loc: { + start: { + line: 1, + column: 0, + }, + end: { + line: 1, + column: 22, + }, + }, + callee: { + type: 'Identifier', + start: 0, + end: 4, + loc: { + start: { + line: 1, + column: 0, + }, + end: { + line: 1, + column: 4, + }, + identifierName: 'from', + }, + name: 'from', + }, + arguments: [ + { + type: 'ObjectExpression', + start: 5, + end: 21, + loc: { + start: { + line: 1, + column: 5, + }, + end: { + line: 1, + column: 21, + }, + }, + properties: [ + { + type: 'ObjectProperty', + start: 6, + end: 20, + loc: { + start: { + line: 1, + column: 6, + }, + end: { + line: 1, + column: 20, + }, + }, + method: false, + shorthand: false, + computed: false, + key: { + type: 'Identifier', + start: 6, + end: 8, + loc: { + start: { + line: 1, + column: 6, + }, + end: { + line: 1, + column: 8, + }, + identifierName: 'db', + }, + name: 'db', + }, + value: { + type: 'StringLiteral', + start: 10, + end: 20, + loc: { + start: { + line: 1, + column: 10, + }, + end: { + line: 1, + column: 20, + }, + }, + extra: { + rawValue: 'telegraf', + raw: 'telegraf', + }, + value: 'telegraf', + }, + }, + ], + }, + ], + }, + }, + ], + directives: [], + }, +} diff --git a/ui/src/ifql/constants/editor.ts b/ui/src/ifql/constants/editor.ts new file mode 100644 index 0000000000..f91858da8e --- /dev/null +++ b/ui/src/ifql/constants/editor.ts @@ -0,0 +1,57 @@ +export const EXCLUDED_KEYS = [ + 'ArrowRight', + 'ArrowLeft', + 'ArrowDown', + 'ArrowUp', + 'Backspace', + 'Tab', + 'Enter', + 'Shift', + 'Ctrl', + 'Control', + 'Alt', + 'Pause', + 'Capslock', + 'Escape', + 'Pageup', + 'Pagedown', + 'End', + 'Home', + 'Left', + 'Up', + 'Right', + 'Down', + 'Insert', + 'Delete', + 'Left window key', + 'Right window key', + 'Select', + 'Add', + 'Subtract', + 'Decimal point', + 'Divide', + 'F1', + 'F2', + 'F3', + 'F4', + 'F5', + 'F6', + 'F7', + 'F8', + 'F9', + 'F10', + 'F11', + 'F12', + 'Numlock', + 'Scrolllock', + 'Semicolon', + 'Equalsign', + 'Comma', + 'Dash', + 'Slash', + 'Graveaccent', + 'Backslash', + 'Quote', + 'Meta', + ' ', +] diff --git a/ui/src/ifql/constants/index.ts b/ui/src/ifql/constants/index.ts index 0fd805dde3..d34a7234f5 100644 --- a/ui/src/ifql/constants/index.ts +++ b/ui/src/ifql/constants/index.ts @@ -1,160 +1,6 @@ import * as funcNames from 'src/ifql/constants/funcNames' import * as argTypes from 'src/ifql/constants/argumentTypes' +import {ast} from 'src/ifql/constants/ast' +import * as editor from 'src/ifql/constants/editor' -const ast = { - type: 'File', - start: 0, - end: 22, - loc: { - start: { - line: 1, - column: 0, - }, - end: { - line: 1, - column: 22, - }, - }, - program: { - type: 'Program', - start: 0, - end: 22, - loc: { - start: { - line: 1, - column: 0, - }, - end: { - line: 1, - column: 22, - }, - }, - sourceType: 'module', - body: [ - { - type: 'ExpressionStatement', - start: 0, - end: 22, - loc: { - start: { - line: 1, - column: 0, - }, - end: { - line: 1, - column: 22, - }, - }, - expression: { - type: 'CallExpression', - start: 0, - end: 22, - loc: { - start: { - line: 1, - column: 0, - }, - end: { - line: 1, - column: 22, - }, - }, - callee: { - type: 'Identifier', - start: 0, - end: 4, - loc: { - start: { - line: 1, - column: 0, - }, - end: { - line: 1, - column: 4, - }, - identifierName: 'from', - }, - name: 'from', - }, - arguments: [ - { - type: 'ObjectExpression', - start: 5, - end: 21, - loc: { - start: { - line: 1, - column: 5, - }, - end: { - line: 1, - column: 21, - }, - }, - properties: [ - { - type: 'ObjectProperty', - start: 6, - end: 20, - loc: { - start: { - line: 1, - column: 6, - }, - end: { - line: 1, - column: 20, - }, - }, - method: false, - shorthand: false, - computed: false, - key: { - type: 'Identifier', - start: 6, - end: 8, - loc: { - start: { - line: 1, - column: 6, - }, - end: { - line: 1, - column: 8, - }, - identifierName: 'db', - }, - name: 'db', - }, - value: { - type: 'StringLiteral', - start: 10, - end: 20, - loc: { - start: { - line: 1, - column: 10, - }, - end: { - line: 1, - column: 20, - }, - }, - extra: { - rawValue: 'telegraf', - raw: 'telegraf', - }, - value: 'telegraf', - }, - }, - ], - }, - ], - }, - }, - ], - directives: [], - }, -} - -export {ast, funcNames, argTypes} +export {ast, funcNames, argTypes, editor} diff --git a/ui/src/ifql/containers/IFQLPage.tsx b/ui/src/ifql/containers/IFQLPage.tsx index 2ad7d99b42..f68386057b 100644 --- a/ui/src/ifql/containers/IFQLPage.tsx +++ b/ui/src/ifql/containers/IFQLPage.tsx @@ -69,24 +69,27 @@ export class IFQLPage extends PureComponent {
-
+

Time Machine

+
+ +
-
-
- -
-
+
diff --git a/ui/src/shared/components/DatabaseList.tsx b/ui/src/shared/components/DatabaseList.tsx index bd22c505f8..7622929128 100644 --- a/ui/src/shared/components/DatabaseList.tsx +++ b/ui/src/shared/components/DatabaseList.tsx @@ -1,5 +1,5 @@ -import PropTypes from 'prop-types' import React, {Component} from 'react' +import PropTypes from 'prop-types' import _ from 'lodash' diff --git a/ui/src/shared/components/FancyScrollbar.js b/ui/src/shared/components/FancyScrollbar.tsx similarity index 54% rename from ui/src/shared/components/FancyScrollbar.js rename to ui/src/shared/components/FancyScrollbar.tsx index 09bf06663f..5f54c67c8d 100644 --- a/ui/src/shared/components/FancyScrollbar.js +++ b/ui/src/shared/components/FancyScrollbar.tsx @@ -1,26 +1,64 @@ +import _ from 'lodash' import React, {Component} from 'react' -import PropTypes from 'prop-types' import classnames from 'classnames' import {Scrollbars} from 'react-custom-scrollbars' import {ErrorHandling} from 'src/shared/decorators/errors' -@ErrorHandling -class FancyScrollbar extends Component { - constructor(props) { - super(props) - } +interface DefaultProps { + autoHide: boolean + autoHeight: boolean + maxHeight: number + setScrollTop: (value: React.MouseEvent) => void + style: React.CSSProperties +} - static defaultProps = { +interface Props { + className?: string + scrollTop?: number + scrollLeft?: number +} + +@ErrorHandling +class FancyScrollbar extends Component> { + public static defaultProps = { autoHide: true, autoHeight: false, + maxHeight: null, + style: {}, setScrollTop: () => {}, } - handleMakeDiv = className => props => { + private ref: React.RefObject + + constructor(props) { + super(props) + this.ref = React.createRef() + } + + public updateScroll() { + const ref = this.ref.current + if (ref && !_.isNil(this.props.scrollTop)) { + ref.scrollTop(this.props.scrollTop) + } + + if (ref && !_.isNil(this.props.scrollLeft)) { + ref.scrollLeft(this.props.scrollLeft) + } + } + + public componentDidMount() { + this.updateScroll() + } + + public componentDidUpdate() { + this.updateScroll() + } + + public handleMakeDiv = (className: string) => (props): JSX.Element => { return
} - render() { + public render() { const { autoHide, autoHeight, @@ -28,6 +66,7 @@ class FancyScrollbar extends Component { className, maxHeight, setScrollTop, + style, } = this.props return ( @@ -35,6 +74,8 @@ class FancyScrollbar extends Component { className={classnames('fancy-scroll--container', { [className]: className, })} + ref={this.ref} + style={style} onScroll={setScrollTop} autoHide={autoHide} autoHideTimeout={1000} @@ -53,15 +94,4 @@ class FancyScrollbar extends Component { } } -const {bool, func, node, number, string} = PropTypes - -FancyScrollbar.propTypes = { - children: node.isRequired, - className: string, - autoHide: bool, - autoHeight: bool, - maxHeight: number, - setScrollTop: func, -} - export default FancyScrollbar diff --git a/ui/src/shared/components/FuncSelectorInput.tsx b/ui/src/shared/components/FuncSelectorInput.tsx index 2415f89f52..8bff3ffde5 100644 --- a/ui/src/shared/components/FuncSelectorInput.tsx +++ b/ui/src/shared/components/FuncSelectorInput.tsx @@ -18,7 +18,7 @@ const FuncSelectorInput: SFC = ({ className="form-control input-sm ifql-func--input" type="text" autoFocus={true} - placeholder="Add Function..." + placeholder="Add a Function..." spellCheck={false} onChange={onFilterChange} onKeyDown={onFilterKeyPress} diff --git a/ui/src/shared/components/Layout.js b/ui/src/shared/components/Layout.js index b38adbdef2..213468c1c9 100644 --- a/ui/src/shared/components/Layout.js +++ b/ui/src/shared/components/Layout.js @@ -23,11 +23,11 @@ const getSource = (cell, source, sources, defaultSource) => { @ErrorHandling class LayoutState extends Component { state = { - celldata: [], + cellData: [], } - grabDataForDownload = celldata => { - this.setState({celldata}) + grabDataForDownload = cellData => { + this.setState({cellData}) } render() { @@ -59,7 +59,7 @@ const Layout = ( source, sources, onZoom, - celldata, + cellData, templates, timeRange, isEditable, @@ -79,7 +79,7 @@ const Layout = ( ) => ( () => { const joinedName = cell.name.split(' ').join('_') - const {celldata} = this.props + const {cellData} = this.props + const {data} = timeSeriesToTableGraph(cellData) + try { - download(dashboardtoCSV(celldata), `${joinedName}.csv`, 'text/plain') + download(dataToCSV(data), `${joinedName}.csv`, 'text/plain') } catch (error) { notify(notifyCSVDownloadFailed()) console.error(error) @@ -34,7 +37,7 @@ class LayoutCell extends Component { } render() { - const {cell, children, isEditable, celldata, onCloneCell} = this.props + const {cell, children, isEditable, cellData, onCloneCell} = this.props const queries = _.get(cell, ['queries'], []) @@ -49,7 +52,7 @@ class LayoutCell extends Component { = {}) { + const { + cellMeasurerCache, + columnIndexOffset = 0, + rowIndexOffset = 0, + } = params + + this.cellMeasurerCache = cellMeasurerCache + this.columnIndexOffset = columnIndexOffset + this.rowIndexOffset = rowIndexOffset + } + + public clear(rowIndex: number, columnIndex: number): void { + this.cellMeasurerCache.clear( + rowIndex + this.rowIndexOffset, + columnIndex + this.columnIndexOffset + ) + } + + public clearAll(): void { + this.cellMeasurerCache.clearAll() + } + + public columnWidth = ({index}: IndexParam) => { + this.cellMeasurerCache.columnWidth({ + index: index + this.columnIndexOffset, + }) + } + + get defaultHeight(): number { + return this.cellMeasurerCache.defaultHeight + } + + get defaultWidth(): number { + return this.cellMeasurerCache.defaultWidth + } + + public hasFixedHeight(): boolean { + return this.cellMeasurerCache.hasFixedHeight() + } + + public hasFixedWidth(): boolean { + return this.cellMeasurerCache.hasFixedWidth() + } + + public getHeight(rowIndex: number, columnIndex: number = 0): number | null { + return this.cellMeasurerCache.getHeight( + rowIndex + this.rowIndexOffset, + columnIndex + this.columnIndexOffset + ) + } + + public getWidth(rowIndex: number, columnIndex: number = 0): number | null { + return this.cellMeasurerCache.getWidth( + rowIndex + this.rowIndexOffset, + columnIndex + this.columnIndexOffset + ) + } + + public has(rowIndex: number, columnIndex: number = 0): boolean { + return this.cellMeasurerCache.has( + rowIndex + this.rowIndexOffset, + columnIndex + this.columnIndexOffset + ) + } + + public rowHeight = ({index}: IndexParam) => { + this.cellMeasurerCache.rowHeight({ + index: index + this.rowIndexOffset, + }) + } + + public set( + rowIndex: number, + columnIndex: number, + width: number, + height: number + ): void { + this.cellMeasurerCache.set( + rowIndex + this.rowIndexOffset, + columnIndex + this.columnIndexOffset, + width, + height + ) + } +} + +export default CellMeasurerCacheDecorator diff --git a/ui/src/shared/components/MultiGrid/MultiGrid.tsx b/ui/src/shared/components/MultiGrid/MultiGrid.tsx new file mode 100644 index 0000000000..585012026a --- /dev/null +++ b/ui/src/shared/components/MultiGrid/MultiGrid.tsx @@ -0,0 +1,813 @@ +import * as React from 'react' +import CellMeasurerCacheDecorator from './CellMeasurerCacheDecorator' +import FancyScrollbar from 'src/shared/components/FancyScrollbar' +import {Grid} from 'react-virtualized' + +const SCROLLBAR_SIZE_BUFFER = 20 + +interface Props { + columnCount?: number + classNameBottomLeftGrid?: string + classNameBottomRightGrid?: string + classNameTopLeftGrid?: string + classNameTopRightGrid?: string + enableFixedColumnScroll?: boolean + enableFixedRowScroll?: boolean + fixedColumnCount?: number + fixedRowCount?: number + style?: object + styleBottomLeftGrid?: object + styleBottomRightGrid?: object + styleTopLeftGrid?: object + styleTopRightGrid?: object + scrollTop?: number + scrollLeft?: number + rowCount?: number + rowHeight?: (arg: {index: number}) => {} | number + columnWidth?: (arg: object) => {} | number + onScroll?: (arg: object) => {} + width: number + height: number + scrollToRow?: () => {} + onSectionRendered?: () => {} + scrollToColumn?: () => {} + cellRenderer?: (arg: object) => JSX.Element +} + +interface State { + scrollLeft: number + scrollTop: number + scrollbarSize: number + showHorizontalScrollbar: boolean + showVerticalScrollbar: boolean +} + +/** + * Renders 1, 2, or 4 Grids depending on configuration. + * A main (body) Grid will always be rendered. + * Optionally, 1-2 Grids for sticky header rows will also be rendered. + * If no sticky columns, only 1 sticky header Grid will be rendered. + * If sticky columns, 2 sticky header Grids will be rendered. + */ +class MultiGrid extends React.PureComponent { + public static defaultProps = { + classNameBottomLeftGrid: '', + classNameBottomRightGrid: '', + classNameTopLeftGrid: '', + classNameTopRightGrid: '', + enableFixedColumnScroll: false, + enableFixedRowScroll: false, + fixedColumnCount: 0, + fixedRowCount: 0, + scrollToColumn: -1, + scrollToRow: -1, + style: {}, + styleBottomLeftGrid: {}, + styleBottomRightGrid: {}, + styleTopLeftGrid: {}, + styleTopRightGrid: {}, + } + + public static getDerivedStateFromProps(nextProps, prevState) { + if ( + nextProps.scrollLeft !== prevState.scrollLeft || + nextProps.scrollTop !== prevState.scrollTop + ) { + return { + scrollLeft: + nextProps.scrollLeft != null && nextProps.scrollLeft >= 0 + ? nextProps.scrollLeft + : prevState.scrollLeft, + scrollTop: + nextProps.scrollTop != null && nextProps.scrollTop >= 0 + ? nextProps.scrollTop + : prevState.scrollTop, + } + } + + return null + } + + private deferredInvalidateColumnIndex: number = 0 + private deferredInvalidateRowIndex: number = 0 + private bottomLeftGrid: Grid + private bottomRightGrid: Grid + private topLeftGrid: Grid + private topRightGrid: Grid + private deferredMeasurementCacheBottomLeftGrid: CellMeasurerCacheDecorator + private deferredMeasurementCacheBottomRightGrid: CellMeasurerCacheDecorator + private deferredMeasurementCacheTopRightGrid: CellMeasurerCacheDecorator + private leftGridWidth: number | null = 0 + private topGridHeight: number | null = 0 + private lastRenderedColumnWidth: (arg: object) => {} | number + private lastRenderedFixedColumnCount: number = 0 + private lastRenderedFixedRowCount: number = 0 + private lastRenderedRowHeight: (arg: {index: number}) => {} | number + private bottomRightGridStyle: object | null + private topRightGridStyle: object | null + private lastRenderedStyle: object | null + private lastRenderedHeight: number = 0 + private lastRenderedWidth: number = 0 + private containerTopStyle: object | null + private containerBottomStyle: object | null + private containerOuterStyle: object | null + private lastRenderedStyleBottomLeftGrid: object | null + private lastRenderedStyleBottomRightGrid: object | null + private lastRenderedStyleTopLeftGrid: object | null + private lastRenderedStyleTopRightGrid: object | null + private bottomLeftGridStyle: object | null + private topLeftGridStyle: object | null + + constructor(props, context) { + super(props, context) + + this.state = { + scrollLeft: 0, + scrollTop: 0, + scrollbarSize: 0, + showHorizontalScrollbar: false, + showVerticalScrollbar: false, + } + + const {deferredMeasurementCache, fixedColumnCount, fixedRowCount} = props + + this.maybeCalculateCachedStyles(true) + + if (deferredMeasurementCache) { + this.deferredMeasurementCacheBottomLeftGrid = + fixedRowCount > 0 + ? new CellMeasurerCacheDecorator({ + cellMeasurerCache: deferredMeasurementCache, + columnIndexOffset: 0, + rowIndexOffset: fixedRowCount, + }) + : deferredMeasurementCache + + this.deferredMeasurementCacheBottomRightGrid = + fixedColumnCount > 0 || fixedRowCount > 0 + ? new CellMeasurerCacheDecorator({ + cellMeasurerCache: deferredMeasurementCache, + columnIndexOffset: fixedColumnCount, + rowIndexOffset: fixedRowCount, + }) + : deferredMeasurementCache + + this.deferredMeasurementCacheTopRightGrid = + fixedColumnCount > 0 + ? new CellMeasurerCacheDecorator({ + cellMeasurerCache: deferredMeasurementCache, + columnIndexOffset: fixedColumnCount, + rowIndexOffset: 0, + }) + : deferredMeasurementCache + } + } + + public forceUpdateGrids() { + if (this.bottomLeftGrid) { + this.bottomLeftGrid.forceUpdate() + } + if (this.bottomRightGrid) { + this.bottomRightGrid.forceUpdate() + } + if (this.topLeftGrid) { + this.topLeftGrid.forceUpdate() + } + if (this.topRightGrid) { + this.topRightGrid.forceUpdate() + } + } + + /** See Grid#invalidateCellSizeAfterRender */ + public invalidateCellSizeAfterRender({columnIndex = 0, rowIndex = 0} = {}) { + this.deferredInvalidateColumnIndex = + typeof this.deferredInvalidateColumnIndex === 'number' + ? Math.min(this.deferredInvalidateColumnIndex, columnIndex) + : columnIndex + this.deferredInvalidateRowIndex = + typeof this.deferredInvalidateRowIndex === 'number' + ? Math.min(this.deferredInvalidateRowIndex, rowIndex) + : rowIndex + } + + /** See Grid#measureAllCells */ + public measureAllCells() { + if (this.bottomLeftGrid) { + this.bottomLeftGrid.measureAllCells() + } + if (this.bottomRightGrid) { + this.bottomRightGrid.measureAllCells() + } + if (this.topLeftGrid) { + this.topLeftGrid.measureAllCells() + } + if (this.topRightGrid) { + this.topRightGrid.measureAllCells() + } + } + + public recomputeGridSize({columnIndex = 0, rowIndex = 0} = {}) { + const {fixedColumnCount, fixedRowCount} = this.props + + const adjustedColumnIndex = Math.max(0, columnIndex - fixedColumnCount) + const adjustedRowIndex = Math.max(0, rowIndex - fixedRowCount) + + if (this.bottomLeftGrid) { + this.bottomLeftGrid.recomputeGridSize({ + columnIndex, + rowIndex: adjustedRowIndex, + }) + } + if (this.bottomRightGrid) { + this.bottomRightGrid.recomputeGridSize({ + columnIndex: adjustedColumnIndex, + rowIndex: adjustedRowIndex, + }) + } + + if (this.topLeftGrid) { + this.topLeftGrid.recomputeGridSize({ + columnIndex, + rowIndex, + }) + } + + if (this.topRightGrid) { + this.topRightGrid.recomputeGridSize({ + columnIndex: adjustedColumnIndex, + rowIndex, + }) + } + + this.leftGridWidth = null + this.topGridHeight = null + this.maybeCalculateCachedStyles(true) + } + + public componentDidMount() { + const {scrollLeft, scrollTop} = this.props + + if (scrollLeft > 0 || scrollTop > 0) { + const newState: Partial = {} + + if (scrollLeft > 0) { + newState.scrollLeft = scrollLeft + } + + if (scrollTop > 0) { + newState.scrollTop = scrollTop + } + + this.setState({...this.state, ...newState}) + } + this.handleInvalidatedGridSize() + } + + public componentDidUpdate() { + this.handleInvalidatedGridSize() + } + + public render() { + const { + onScroll, + scrollLeft: scrollLeftProp, // eslint-disable-line no-unused-vars + onSectionRendered, + scrollToRow, + scrollToColumn, + scrollTop: scrollTopProp, // eslint-disable-line no-unused-vars + ...rest + } = this.props + + this.prepareForRender() + + // Don't render any of our Grids if there are no cells. + // This mirrors what Grid does, + // And prevents us from recording inaccurage measurements when used with CellMeasurer. + if (this.props.width === 0 || this.props.height === 0) { + return null + } + + // scrollTop and scrollLeft props are explicitly filtered out and ignored + + const {scrollLeft, scrollTop} = this.state + + return ( +
+
+ {this.renderTopLeftGrid(rest)} + {this.renderTopRightGrid({ + ...rest, + ...onScroll, + scrollLeft, + })} +
+
+ {this.renderBottomLeftGrid({ + ...rest, + onScroll, + scrollTop, + })} + {this.renderBottomRightGrid({ + ...rest, + onScroll, + onSectionRendered, + scrollLeft, + scrollToColumn, + scrollToRow, + scrollTop, + })} +
+
+ ) + } + + public cellRendererBottomLeftGrid = ({ + rowIndex, + ...rest + }: Partial & {rowIndex: number; key: string}): JSX.Element => { + const {cellRenderer, fixedRowCount, rowCount} = this.props + + if (rowIndex === rowCount - fixedRowCount) { + return ( +
+ ) + } else { + return cellRenderer({ + ...rest, + parent: this, + rowIndex: rowIndex + fixedRowCount, + }) + } + } + + private getBottomGridHeight(props) { + const {height} = props + + const topGridHeight = this.getTopGridHeight(props) + + return height - topGridHeight + } + + private getLeftGridWidth(props) { + const {fixedColumnCount, columnWidth} = props + + if (this.leftGridWidth == null) { + if (typeof columnWidth === 'function') { + let leftGridWidth = 0 + + for (let index = 0; index < fixedColumnCount; index++) { + leftGridWidth += columnWidth({index}) + } + + this.leftGridWidth = leftGridWidth + } else { + this.leftGridWidth = columnWidth * fixedColumnCount + } + } + + return this.leftGridWidth + } + + private getRightGridWidth(props) { + const {width} = props + + const leftGridWidth = this.getLeftGridWidth(props) + const result = width - leftGridWidth + + return result + } + + private getTopGridHeight(props) { + const {fixedRowCount, rowHeight} = props + + if (this.topGridHeight == null) { + if (typeof rowHeight === 'function') { + let topGridHeight = 0 + + for (let index = 0; index < fixedRowCount; index++) { + topGridHeight += rowHeight({index}) + } + + this.topGridHeight = topGridHeight + } else { + this.topGridHeight = rowHeight * fixedRowCount + } + } + + return this.topGridHeight + } + + private onScrollbarsScroll = (e: React.MouseEvent) => { + const {target} = e + this.onScroll(target) + } + + private onScroll = scrollInfo => { + const {scrollLeft, scrollTop} = scrollInfo + this.setState({ + scrollLeft, + scrollTop, + }) + + const {onScroll} = this.props + if (onScroll) { + onScroll(scrollInfo) + } + } + + private onScrollLeft = scrollInfo => { + const {scrollLeft} = scrollInfo + this.onScroll({ + scrollLeft, + scrollTop: this.state.scrollTop, + }) + } + + private renderBottomLeftGrid(props) { + const {fixedColumnCount, fixedRowCount, rowCount} = props + + if (!fixedColumnCount) { + return null + } + + const width = this.getLeftGridWidth(props) + const height = this.getBottomGridHeight(props) + + return ( + + ) + } + + private renderBottomRightGrid(props) { + const { + columnCount, + fixedColumnCount, + fixedRowCount, + rowCount, + scrollToColumn, + scrollToRow, + } = props + + const width = this.getRightGridWidth(props) + const height = this.getBottomGridHeight(props) + + return ( + + + + ) + } + + private renderTopLeftGrid(props) { + const {fixedColumnCount, fixedRowCount} = props + + if (!fixedColumnCount || !fixedRowCount) { + return null + } + + return ( + + ) + } + + private renderTopRightGrid(props) { + const { + columnCount, + enableFixedRowScroll, + fixedColumnCount, + fixedRowCount, + scrollLeft, + } = props + + if (!fixedRowCount) { + return null + } + + const width = this.getRightGridWidth(props) + const height = this.getTopGridHeight(props) + + return ( + + ) + } + + private rowHeightBottomGrid = ({index}) => { + const {fixedRowCount, rowCount, rowHeight} = this.props + const {scrollbarSize, showVerticalScrollbar} = this.state + + // An extra cell is added to the count + // This gives the smaller Grid extra room for offset, + // In case the main (bottom right) Grid has a scrollbar + // If no scrollbar, the extra space is overflow:hidden anyway + if (showVerticalScrollbar && index === rowCount - fixedRowCount) { + return scrollbarSize + } + + return typeof rowHeight === 'function' + ? rowHeight({index: index + fixedRowCount}) + : rowHeight + } + + private topLeftGridRef = ref => { + this.topLeftGrid = ref + } + + private topRightGridRef = ref => { + this.topRightGrid = ref + } + + /** + * Avoid recreating inline styles each render; this bypasses Grid's shallowCompare. + * This method recalculates styles only when specific props change. + */ + private maybeCalculateCachedStyles(resetAll) { + const { + columnWidth, + height, + fixedColumnCount, + fixedRowCount, + rowHeight, + style, + styleBottomLeftGrid, + styleBottomRightGrid, + styleTopLeftGrid, + styleTopRightGrid, + width, + } = this.props + + const sizeChange = + resetAll || + height !== this.lastRenderedHeight || + width !== this.lastRenderedWidth + const leftSizeChange = + resetAll || + columnWidth !== this.lastRenderedColumnWidth || + fixedColumnCount !== this.lastRenderedFixedColumnCount + const topSizeChange = + resetAll || + fixedRowCount !== this.lastRenderedFixedRowCount || + rowHeight !== this.lastRenderedRowHeight + + if (resetAll || sizeChange || style !== this.lastRenderedStyle) { + this.containerOuterStyle = { + height, + overflow: 'visible', // Let :focus outline show through + width, + ...style, + } + } + + if (resetAll || sizeChange || topSizeChange) { + this.containerTopStyle = { + height: this.getTopGridHeight(this.props), + position: 'relative', + width, + } + + this.containerBottomStyle = { + height: height - this.getTopGridHeight(this.props), + overflow: 'visible', // Let :focus outline show through + position: 'relative', + width, + } + } + + if ( + resetAll || + styleBottomLeftGrid !== this.lastRenderedStyleBottomLeftGrid + ) { + this.bottomLeftGridStyle = { + left: 0, + overflowY: 'hidden', + overflowX: 'hidden', + position: 'absolute', + ...styleBottomLeftGrid, + } + } + + if ( + resetAll || + leftSizeChange || + styleBottomRightGrid !== this.lastRenderedStyleBottomRightGrid + ) { + this.bottomRightGridStyle = { + left: this.getLeftGridWidth(this.props), + position: 'absolute', + ...styleBottomRightGrid, + } + } + + if (resetAll || styleTopLeftGrid !== this.lastRenderedStyleTopLeftGrid) { + this.topLeftGridStyle = { + left: 0, + overflowX: 'hidden', + overflowY: 'hidden', + position: 'absolute', + top: 0, + ...styleTopLeftGrid, + } + } + + if ( + resetAll || + leftSizeChange || + styleTopRightGrid !== this.lastRenderedStyleTopRightGrid + ) { + this.topRightGridStyle = { + left: this.getLeftGridWidth(this.props), + overflowX: 'hidden', + overflowY: 'hidden', + position: 'absolute', + top: 0, + ...styleTopRightGrid, + } + } + + this.lastRenderedColumnWidth = columnWidth + this.lastRenderedFixedColumnCount = fixedColumnCount + this.lastRenderedFixedRowCount = fixedRowCount + this.lastRenderedHeight = height + this.lastRenderedRowHeight = rowHeight + this.lastRenderedStyle = style + this.lastRenderedStyleBottomLeftGrid = styleBottomLeftGrid + this.lastRenderedStyleBottomRightGrid = styleBottomRightGrid + this.lastRenderedStyleTopLeftGrid = styleTopLeftGrid + this.lastRenderedStyleTopRightGrid = styleTopRightGrid + this.lastRenderedWidth = width + } + + private bottomLeftGridRef = ref => { + this.bottomLeftGrid = ref + } + + private bottomRightGridRef = ref => { + this.bottomRightGrid = ref + } + + private cellRendererBottomRightGrid = ({columnIndex, rowIndex, ...rest}) => { + const {cellRenderer, fixedColumnCount, fixedRowCount} = this.props + + return cellRenderer({ + ...rest, + columnIndex: columnIndex + fixedColumnCount, + parent: this, + rowIndex: rowIndex + fixedRowCount, + }) + } + + private cellRendererTopRightGrid = ({columnIndex, ...rest}) => { + const {cellRenderer, columnCount, fixedColumnCount} = this.props + + if (columnIndex === columnCount - fixedColumnCount) { + return ( +
+ ) + } else { + return cellRenderer({ + ...rest, + columnIndex: columnIndex + fixedColumnCount, + parent: this, + }) + } + } + + private columnWidthRightGrid = ({index}) => { + const {columnCount, fixedColumnCount, columnWidth} = this.props + const {scrollbarSize, showHorizontalScrollbar} = this.state + + // An extra cell is added to the count + // This gives the smaller Grid extra room for offset, + // In case the main (bottom right) Grid has a scrollbar + // If no scrollbar, the extra space is overflow:hidden anyway + if (showHorizontalScrollbar && index === columnCount - fixedColumnCount) { + return scrollbarSize + } + + return typeof columnWidth === 'function' + ? columnWidth({index: index + fixedColumnCount}) + : columnWidth + } + + private handleInvalidatedGridSize() { + if (typeof this.deferredInvalidateColumnIndex === 'number') { + const columnIndex = this.deferredInvalidateColumnIndex + const rowIndex = this.deferredInvalidateRowIndex + + this.deferredInvalidateColumnIndex = null + this.deferredInvalidateRowIndex = null + + this.recomputeGridSize({ + columnIndex, + rowIndex, + }) + this.forceUpdate() + } + } + + private prepareForRender() { + if ( + this.lastRenderedColumnWidth !== this.props.columnWidth || + this.lastRenderedFixedColumnCount !== this.props.fixedColumnCount + ) { + this.leftGridWidth = null + } + + if ( + this.lastRenderedFixedRowCount !== this.props.fixedRowCount || + this.lastRenderedRowHeight !== this.props.rowHeight + ) { + this.topGridHeight = null + } + + this.maybeCalculateCachedStyles(false) + + this.lastRenderedColumnWidth = this.props.columnWidth + this.lastRenderedFixedColumnCount = this.props.fixedColumnCount + this.lastRenderedFixedRowCount = this.props.fixedRowCount + this.lastRenderedRowHeight = this.props.rowHeight + } +} + +export default MultiGrid diff --git a/ui/src/shared/components/MultiGrid/index.ts b/ui/src/shared/components/MultiGrid/index.ts new file mode 100644 index 0000000000..5f900bac60 --- /dev/null +++ b/ui/src/shared/components/MultiGrid/index.ts @@ -0,0 +1,2 @@ +import MultiGrid from './MultiGrid' +export {MultiGrid} diff --git a/ui/src/shared/components/RefreshingGraph.js b/ui/src/shared/components/RefreshingGraph.js index 2e4d1586a2..c8369e960e 100644 --- a/ui/src/shared/components/RefreshingGraph.js +++ b/ui/src/shared/components/RefreshingGraph.js @@ -118,6 +118,7 @@ const RefreshingGraph = ({ decimalPlaces={decimalPlaces} editQueryStatus={editQueryStatus} resizerTopHeight={resizerTopHeight} + grabDataForDownload={grabDataForDownload} handleSetHoverTime={handleSetHoverTime} isInCEO={isInCEO} /> diff --git a/ui/src/shared/components/ResizeDivision.tsx b/ui/src/shared/components/ResizeDivision.tsx new file mode 100644 index 0000000000..4b94093acf --- /dev/null +++ b/ui/src/shared/components/ResizeDivision.tsx @@ -0,0 +1,209 @@ +import React, {PureComponent, ReactElement, MouseEvent} from 'react' +import classnames from 'classnames' +import calculateSize from 'calculate-size' + +import {HANDLE_VERTICAL, HANDLE_HORIZONTAL} from 'src/shared/constants/index' + +const NOOP = () => {} + +interface Props { + name?: string + handleDisplay?: string + handlePixels: number + id: string + size: number + offset: number + draggable: boolean + orientation: string + activeHandleID: string + render: () => ReactElement + onHandleStartDrag: (id: string, e: MouseEvent) => void + onDoubleClick: (id: string) => void +} + +class Division extends PureComponent { + public static defaultProps: Partial = { + name: '', + handleDisplay: 'visible', + } + + private collapseThreshold: number = 0 + private containerRef: HTMLElement + + public componentDidMount() { + const {name} = this.props + + if (!name) { + return 0 + } + + const {width} = calculateSize(name, { + font: '"Roboto", Helvetica, Arial, Tahoma, Verdana, sans-serif', + fontSize: '16px', + fontWeight: '500', + }) + const NAME_OFFSET = 66 + + this.collapseThreshold = width + NAME_OFFSET + } + + public render() { + const {name, render, draggable} = this.props + return ( +
(this.containerRef = r)} + > +
+
{name}
+
+
+ {name &&
} +
{render()}
+
+
+ ) + } + + private get title() { + return 'Drag to resize.\nDouble click to expand.' + } + + private get contentStyle() { + if (this.props.orientation === HANDLE_HORIZONTAL) { + return { + height: `calc(100% - ${this.handlePixels}px)`, + } + } + + return { + width: `calc(100% - ${this.handlePixels}px)`, + } + } + + private get handleStyle() { + const {handleDisplay: display, orientation, handlePixels} = this.props + + if (orientation === HANDLE_HORIZONTAL) { + return { + display, + height: `${handlePixels}px`, + } + } + + return { + display, + width: `${handlePixels}px`, + } + } + + private get containerStyle() { + if (this.props.orientation === HANDLE_HORIZONTAL) { + return { + height: this.size, + } + } + + return { + width: this.size, + } + } + + private get size(): string { + const {size, offset} = this.props + return `calc((100% - ${offset}px) * ${size} + ${this.handlePixels}px)` + } + + private get handlePixels(): number { + if (this.props.handleDisplay === 'none') { + return 0 + } + + return this.props.handlePixels + } + + private get containerClass(): string { + const {orientation} = this.props + const isAnyHandleBeingDragged = !!this.props.activeHandleID + return classnames('threesizer--division', { + dragging: isAnyHandleBeingDragged, + vertical: orientation === HANDLE_VERTICAL, + horizontal: orientation === HANDLE_HORIZONTAL, + }) + } + + private get handleClass(): string { + const {draggable, orientation} = this.props + + return classnames('threesizer--handle', { + disabled: !draggable, + dragging: this.isDragging, + vertical: orientation === HANDLE_VERTICAL, + horizontal: orientation === HANDLE_HORIZONTAL, + }) + } + + private get contentsClass(): string { + const {orientation, size} = this.props + return classnames(`threesizer--contents ${orientation}`, { + 'no-shadows': !size, + }) + } + + private get titleClass(): string { + const {orientation} = this.props + + const collapsed = orientation === HANDLE_VERTICAL && this.isTitleObscured + + return classnames('threesizer--title', { + 'threesizer--collapsed': collapsed, + vertical: orientation === HANDLE_VERTICAL, + horizontal: orientation === HANDLE_HORIZONTAL, + }) + } + + private get isTitleObscured(): boolean { + if (this.props.size === 0) { + return true + } + + if (!this.containerRef || this.props.size >= 0.33) { + return false + } + + const {width} = this.containerRef.getBoundingClientRect() + + return width <= this.collapseThreshold + } + + private get isDragging(): boolean { + const {id, activeHandleID} = this.props + return id === activeHandleID + } + + private drag = e => { + const {draggable, id} = this.props + + if (!draggable) { + return NOOP + } + + this.props.onHandleStartDrag(id, e) + } + + private handleDoubleClick = () => { + const {onDoubleClick, id} = this.props + + onDoubleClick(id) + } +} + +export default Division diff --git a/ui/src/shared/components/TableGraph.js b/ui/src/shared/components/TableGraph.js index fa9c9b97ff..cc5326ec70 100644 --- a/ui/src/shared/components/TableGraph.js +++ b/ui/src/shared/components/TableGraph.js @@ -4,7 +4,8 @@ import _ from 'lodash' import classnames from 'classnames' import {connect} from 'react-redux' -import {MultiGrid, ColumnSizer} from 'react-virtualized' +import {ColumnSizer} from 'react-virtualized' +import {MultiGrid} from 'src/shared/components/MultiGrid' import {bindActionCreators} from 'redux' import moment from 'moment' import {reduce} from 'fast.js' diff --git a/ui/src/shared/components/Tags.tsx b/ui/src/shared/components/Tags.tsx index 130791be43..6f93a547d9 100644 --- a/ui/src/shared/components/Tags.tsx +++ b/ui/src/shared/components/Tags.tsx @@ -11,6 +11,7 @@ interface Item { interface TagsProps { tags: Item[] + confirmText?: string onDeleteTag?: (item: Item) => void addMenuItems?: Item[] addMenuChoose?: (item: Item) => void @@ -21,11 +22,19 @@ const Tags: SFC = ({ onDeleteTag, addMenuItems, addMenuChoose, + confirmText, }) => { return (
{tags.map(item => { - return + return ( + + ) })} {addMenuItems && addMenuItems.length && addMenuChoose ? ( @@ -35,23 +44,28 @@ const Tags: SFC = ({ } interface TagProps { + confirmText?: string item: Item onDelete: (item: Item) => void } @ErrorHandling class Tag extends PureComponent { + public static defaultProps: Partial = { + confirmText: 'Delete', + } + public render() { - const {item} = this.props + const {item, confirmText} = this.props return ( {item.text || item.name || item} diff --git a/ui/src/shared/components/Threesizer.tsx b/ui/src/shared/components/Threesizer.tsx new file mode 100644 index 0000000000..687c312e19 --- /dev/null +++ b/ui/src/shared/components/Threesizer.tsx @@ -0,0 +1,451 @@ +import React, {Component, ReactElement, MouseEvent} from 'react' +import classnames from 'classnames' +import uuid from 'uuid' +import _ from 'lodash' + +import ResizeDivision from 'src/shared/components/ResizeDivision' +import {ErrorHandling} from 'src/shared/decorators/errors' +import { + HANDLE_NONE, + HANDLE_PIXELS, + HANDLE_HORIZONTAL, + HANDLE_VERTICAL, + MIN_SIZE, + MAX_SIZE, +} from 'src/shared/constants/' + +const initialDragEvent = { + percentX: 0, + percentY: 0, + mouseX: null, + mouseY: null, +} + +interface State { + activeHandleID: string + divisions: DivisionState[] + dragDirection: string + dragEvent: any +} + +interface Division { + name?: string + handleDisplay?: string + handlePixels?: number + render: () => ReactElement +} + +interface DivisionState extends Division { + id: string + size: number +} + +interface Props { + divisions: Division[] + orientation: string + containerClass?: string +} + +@ErrorHandling +class Threesizer extends Component { + public static defaultProps: Partial = { + orientation: HANDLE_HORIZONTAL, + containerClass: '', + } + + private containerRef: HTMLElement + private percentChangeX: number = 0 + private percentChangeY: number = 0 + + constructor(props) { + super(props) + this.state = { + activeHandleID: null, + divisions: this.initialDivisions, + dragEvent: initialDragEvent, + dragDirection: '', + } + } + + public componentDidMount() { + document.addEventListener('mouseup', this.handleStopDrag) + document.addEventListener('mouseleave', this.handleStopDrag) + } + + public componentWillUnmount() { + document.removeEventListener('mouseup', this.handleStopDrag) + document.removeEventListener('mouseleave', this.handleStopDrag) + } + + public componentDidUpdate(__, prevState) { + const {dragEvent} = this.state + const {orientation} = this.props + + if (_.isEqual(dragEvent, prevState.dragEvent)) { + return + } + + this.percentChangeX = this.pixelsToPercentX( + prevState.dragEvent.mouseX, + dragEvent.mouseX + ) + + this.percentChangeY = this.pixelsToPercentY( + prevState.dragEvent.mouseY, + dragEvent.mouseY + ) + + const {percentX, percentY} = dragEvent + const {dragEvent: prevDrag} = prevState + + if (orientation === HANDLE_VERTICAL) { + const left = percentX < prevDrag.percentX + + if (left) { + return this.move.left() + } + + return this.move.right() + } + + const up = percentY < prevDrag.percentY + + if (up) { + return this.move.up() + } + + return this.move.down() + } + + public render() { + const {activeHandleID, divisions} = this.state + const {orientation} = this.props + + return ( +
(this.containerRef = r)} + > + {divisions.map((d, i) => ( + 0} + orientation={orientation} + handlePixels={d.handlePixels} + handleDisplay={d.handleDisplay} + activeHandleID={activeHandleID} + onDoubleClick={this.handleDoubleClick} + onHandleStartDrag={this.handleStartDrag} + render={this.props.divisions[i].render} + /> + ))} +
+ ) + } + + private get offset(): number { + const handlesPixelCount = this.state.divisions.reduce((acc, d) => { + if (d.handleDisplay === HANDLE_NONE) { + return acc + } + + return acc + d.handlePixels + }, 0) + + return handlesPixelCount + } + + private get className(): string { + const {orientation, containerClass} = this.props + const {activeHandleID} = this.state + + return classnames(`threesizer ${containerClass}`, { + dragging: activeHandleID, + horizontal: orientation === HANDLE_HORIZONTAL, + vertical: orientation === HANDLE_VERTICAL, + }) + } + + private get initialDivisions() { + const {divisions} = this.props + + const size = 1 / divisions.length + + return divisions.map(d => ({ + ...d, + id: uuid.v4(), + size, + handlePixels: d.handlePixels || HANDLE_PIXELS, + })) + } + + private handleDoubleClick = (id: string): void => { + const clickedDiv = this.state.divisions.find(d => d.id === id) + + if (!clickedDiv) { + return + } + + const isMaxed = clickedDiv.size === 1 + + if (isMaxed) { + return this.equalize() + } + + const divisions = this.state.divisions.map(d => { + if (d.id !== id) { + return {...d, size: 0} + } + + return {...d, size: 1} + }) + + this.setState({divisions}) + } + + private equalize = () => { + const denominator = this.state.divisions.length + const divisions = this.state.divisions.map(d => { + return {...d, size: 1 / denominator} + }) + + this.setState({divisions}) + } + + private handleStartDrag = (activeHandleID, e: MouseEvent) => { + const dragEvent = this.mousePosWithinContainer(e) + this.setState({activeHandleID, dragEvent}) + } + + private handleStopDrag = () => { + this.setState({activeHandleID: '', dragEvent: initialDragEvent}) + } + + private mousePosWithinContainer = (e: MouseEvent) => { + const {pageY, pageX} = e + const {top, left, width, height} = this.containerRef.getBoundingClientRect() + + const mouseX = pageX - left + const mouseY = pageY - top + + const percentX = mouseX / width + const percentY = mouseY / height + + return { + mouseX, + mouseY, + percentX, + percentY, + } + } + + private pixelsToPercentX = (startValue, endValue) => { + if (!startValue || !endValue) { + return 0 + } + + const delta = Math.abs(startValue - endValue) + const {width} = this.containerRef.getBoundingClientRect() + + return delta / width + } + + private pixelsToPercentY = (startValue, endValue) => { + if (!startValue || !endValue) { + return 0 + } + + const delta = startValue - endValue + const {height} = this.containerRef.getBoundingClientRect() + + return Math.abs(delta / height) + } + + private handleDrag = (e: MouseEvent) => { + const {activeHandleID} = this.state + if (!activeHandleID) { + return + } + + const dragEvent = this.mousePosWithinContainer(e) + this.setState({dragEvent}) + } + + private get move() { + const {activeHandleID} = this.state + + const activePosition = _.findIndex( + this.state.divisions, + d => d.id === activeHandleID + ) + + return { + up: this.up(activePosition), + down: this.down(activePosition), + left: this.left(activePosition), + right: this.right(activePosition), + } + } + + private up = activePosition => () => { + const divisions = this.state.divisions.map((d, i) => { + if (!activePosition) { + return d + } + + const first = i === 0 + const before = i === activePosition - 1 + const current = i === activePosition + + if (first && !before) { + const second = this.state.divisions[1] + if (second.size === 0) { + return {...d, size: this.shorter(d.size)} + } + + return {...d} + } + + if (before) { + return {...d, size: this.shorter(d.size)} + } + + if (current) { + return {...d, size: this.taller(d.size)} + } + + return {...d} + }) + + this.setState({divisions}) + } + + private left = activePosition => () => { + const divisions = this.state.divisions.map((d, i) => { + if (!activePosition) { + return d + } + + const first = i === 0 + const before = i === activePosition - 1 + const active = i === activePosition + + if (first && !before) { + const second = this.state.divisions[1] + if (second.size === 0) { + return {...d, size: this.thinner(d.size)} + } + + return {...d} + } + + if (before) { + return {...d, size: this.thinner(d.size)} + } + + if (active) { + return {...d, size: this.fatter(d.size)} + } + + return {...d} + }) + + this.setState({divisions}) + } + + private right = activePosition => () => { + const divisions = this.state.divisions.map((d, i, divs) => { + const before = i === activePosition - 1 + const active = i === activePosition + const after = i === activePosition + 1 + + if (before) { + return {...d, size: this.fatter(d.size)} + } + + if (active) { + return {...d, size: this.thinner(d.size)} + } + + if (after) { + const leftIndex = i - 1 + const left = _.get(divs, leftIndex, {size: 'none'}) + + if (left.size === 0) { + return {...d, size: this.thinner(d.size)} + } + + return {...d} + } + + return {...d} + }) + + this.setState({divisions}) + } + + private down = activePosition => () => { + const divisions = this.state.divisions.map((d, i, divs) => { + const before = i === activePosition - 1 + const current = i === activePosition + const after = i === activePosition + 1 + + if (before) { + return {...d, size: this.taller(d.size)} + } + + if (current) { + return {...d, size: this.shorter(d.size)} + } + + if (after) { + const above = divs[i - 1] + if (above.size === 0) { + return {...d, size: this.shorter(d.size)} + } + + return {...d} + } + + return {...d} + }) + + this.setState({divisions}) + } + + private taller = (size: number): number => { + const newSize = size + this.percentChangeY + return this.enforceMax(newSize) + } + + private fatter = (size: number): number => { + const newSize = size + this.percentChangeX + return this.enforceMax(newSize) + } + + private shorter = (size: number): number => { + const newSize = size - this.percentChangeY + return this.enforceMin(newSize) + } + + private thinner = (size: number): number => { + const newSize = size - this.percentChangeX + return this.enforceMin(newSize) + } + + private enforceMax = (size: number): number => { + return size > MAX_SIZE ? MAX_SIZE : size + } + + private enforceMin = (size: number): number => { + return size < MIN_SIZE ? MIN_SIZE : size + } +} + +export default Threesizer diff --git a/ui/src/shared/constants/index.tsx b/ui/src/shared/constants/index.tsx index 26acce1c77..4dd35dcd25 100644 --- a/ui/src/shared/constants/index.tsx +++ b/ui/src/shared/constants/index.tsx @@ -402,7 +402,7 @@ export const HTTP_UNAUTHORIZED = 401 export const HTTP_FORBIDDEN = 403 export const HTTP_NOT_FOUND = 404 -export const AUTOREFRESH_DEFAULT = 15000 // in milliseconds +export const AUTOREFRESH_DEFAULT = 0 // in milliseconds export const GRAPH = 'graph' export const TABLE = 'table' @@ -477,3 +477,13 @@ export const NOTIFICATION_TRANSITION = 250 export const FIVE_SECONDS = 5000 export const TEN_SECONDS = 10000 export const INFINITE = -1 + +// Resizer && Threesizer +export const HUNDRED = 100 +export const REQUIRED_HALVES = 2 +export const HANDLE_VERTICAL = 'vertical' +export const HANDLE_HORIZONTAL = 'horizontal' +export const HANDLE_NONE = 'none' +export const HANDLE_PIXELS = 30 +export const MAX_SIZE = 1 +export const MIN_SIZE = 0 diff --git a/ui/src/shared/copy/notifications.js b/ui/src/shared/copy/notifications.js index 26d88f5964..516970a8fd 100644 --- a/ui/src/shared/copy/notifications.js +++ b/ui/src/shared/copy/notifications.js @@ -424,13 +424,6 @@ export const notifyCellAdded = name => ({ message: `Added "${name}" to dashboard.`, }) -export const notifyCellCloned = name => ({ - ...defaultSuccessNotification, - icon: 'duplicate', - duration: 1900, - message: `Added "${name}" to dashboard.`, -}) - export const notifyCellDeleted = name => ({ ...defaultDeletionNotification, icon: 'dash-h', diff --git a/ui/src/shared/parsing/dataToCSV.js b/ui/src/shared/parsing/dataToCSV.js new file mode 100644 index 0000000000..8edbd4afca --- /dev/null +++ b/ui/src/shared/parsing/dataToCSV.js @@ -0,0 +1,26 @@ +import _ from 'lodash' +import moment from 'moment' +import {map} from 'fast.js' + +export const formatDate = timestamp => + moment(timestamp).format('M/D/YYYY h:mm:ss.SSSSSSSSS A') + +export const dataToCSV = ([titleRow, ...valueRows]) => { + if (_.isEmpty(titleRow)) { + return '' + } + if (_.isEmpty(valueRows)) { + return ['date', titleRow.slice(1)].join(',') + } + if (titleRow[0] === 'time') { + const titlesString = ['date', titleRow.slice(1)].join(',') + + const valuesString = map(valueRows, ([timestamp, ...values]) => [ + [formatDate(timestamp), ...values].join(','), + ]).join('\n') + return `${titlesString}\n${valuesString}` + } + const allRows = [titleRow, ...valueRows] + const allRowsStringArray = map(allRows, r => r.join(',')) + return allRowsStringArray.join('\n') +} diff --git a/ui/src/shared/parsing/resultsToCSV.js b/ui/src/shared/parsing/resultsToCSV.js deleted file mode 100644 index c3d3fdd020..0000000000 --- a/ui/src/shared/parsing/resultsToCSV.js +++ /dev/null @@ -1,53 +0,0 @@ -import _ from 'lodash' -import moment from 'moment' - -export const formatDate = timestamp => - moment(timestamp).format('M/D/YYYY h:mm:ss.SSSSSSSSS A') - -export const resultsToCSV = results => { - if (!_.get(results, ['0', 'series', '0'])) { - return {flag: 'no_data', name: '', CSVString: ''} - } - - const {name, columns, values} = _.get(results, ['0', 'series', '0']) - - if (columns[0] === 'time') { - const [, ...cols] = columns - const CSVString = [['date', ...cols].join(',')] - .concat( - values.map(([timestamp, ...measurements]) => - // MS Excel format - [formatDate(timestamp), ...measurements].join(',') - ) - ) - .join('\n') - return {flag: 'ok', name, CSVString} - } - - const CSVString = [columns.join(',')] - .concat(values.map(row => row.join(','))) - .join('\n') - return {flag: 'ok', name, CSVString} -} - -export const dashboardtoCSV = data => { - const columnNames = _.flatten( - data.map(r => _.get(r, 'results[0].series[0].columns', [])) - ) - const timeIndices = columnNames - .map((e, i) => (e === 'time' ? i : -1)) - .filter(e => e >= 0) - - let values = data.map(r => _.get(r, 'results[0].series[0].values', [])) - values = _.unzip(values).map(v => _.flatten(v)) - if (timeIndices) { - values.map(v => { - timeIndices.forEach(i => (v[i] = formatDate(v[i]))) - return v - }) - } - const CSVString = [columnNames.join(',')] - .concat(values.map(v => v.join(','))) - .join('\n') - return CSVString -} diff --git a/ui/src/style/chronograf.scss b/ui/src/style/chronograf.scss index 6bb48c8834..1e8206a350 100644 --- a/ui/src/style/chronograf.scss +++ b/ui/src/style/chronograf.scss @@ -68,9 +68,9 @@ @import 'components/source-selector'; @import 'components/tables'; @import 'components/table-graph'; +@import 'components/threesizer'; @import 'components/threshold-controls'; @import 'components/kapacitor-logs-table'; -@import 'components/func-node.scss'; // Pages @import 'pages/config-endpoints'; @@ -81,11 +81,8 @@ @import 'pages/admin'; @import 'pages/users'; @import 'pages/tickscript-editor'; +@import 'pages/time-machine'; @import 'pages/manage-providers'; // TODO @import 'unsorted'; - -// IFQL - Time Machine -@import 'components/funcs-button'; -@import 'components/time-machine'; diff --git a/ui/src/style/components/code-mirror-theme.scss b/ui/src/style/components/code-mirror-theme.scss index 242890443e..0545c00f30 100644 --- a/ui/src/style/components/code-mirror-theme.scss +++ b/ui/src/style/components/code-mirror-theme.scss @@ -20,73 +20,190 @@ font-weight: 600; height: 100%; } + .CodeMirror-vscrollbar { - @include custom-scrollbar-round($g2-kevlar,$g6-smoke); + @include custom-scrollbar-round($g2-kevlar, $g6-smoke); } + .CodeMirror-hscrollbar { - @include custom-scrollbar-round($g0-obsidian,$g6-smoke); + @include custom-scrollbar-round($g0-obsidian, $g6-smoke); } + .cm-s-material .CodeMirror-gutters { - @include gradient-v($g2-kevlar, $g0-obsidian) - border: none; + @include gradient-v($g2-kevlar, $g0-obsidian) border: none; } + .cm-s-material .CodeMirror-gutters .CodeMirror-gutter { background-color: fade-out($g4-onyx, 0.75); height: calc(100% + 30px); } + .CodeMirror-gutter.CodeMirror-linenumbers { width: 60px; } + .cm-s-material.CodeMirror .CodeMirror-sizer { margin-left: 60px; } + .cm-s-material.CodeMirror .CodeMirror-linenumber.CodeMirror-gutter-elt { padding-right: 9px; width: 46px; color: $g8-storm; } -.cm-s-material .CodeMirror-guttermarker, .cm-s-material .CodeMirror-guttermarker-subtle, .cm-s-material .CodeMirror-linenumber { color: rgb(83,127,126); } + +.cm-s-material .CodeMirror-guttermarker, +.cm-s-material .CodeMirror-guttermarker-subtle, +.cm-s-material .CodeMirror-linenumber { + color: rgb(83, 127, 126); +} + .cm-s-material .CodeMirror-cursor { width: 2px; border: 0; background-color: $g20-white; - box-shadow: - 0 0 3px $c-laser, - 0 0 6px $c-ocean, - 0 0 11px $c-amethyst; + box-shadow: 0 0 3px $c-laser, 0 0 6px $c-ocean, 0 0 11px $c-amethyst; } + .cm-s-material div.CodeMirror-selected, .cm-s-material.CodeMirror-focused div.CodeMirror-selected { - background-color: fade-out($g8-storm,0.7); + background-color: fade-out($g8-storm, 0.7); +} + +.cm-s-material .CodeMirror-line::selection, +.cm-s-material .CodeMirror-line>span::selection, +.cm-s-material .CodeMirror-line>span>span::selection { + background: rgba(255, 255, 255, 0.10); +} + +.cm-s-material .CodeMirror-line::-moz-selection, +.cm-s-material .CodeMirror-line>span::-moz-selection, +.cm-s-material .CodeMirror-line>span>span::-moz-selection { + background: rgba(255, 255, 255, 0.10); +} + +.cm-s-material .CodeMirror-activeline-background { + background: rgba(0, 0, 0, 0); +} + +.cm-s-material .cm-keyword { + color: $c-comet; +} + +.cm-s-material .cm-operator { + color: $c-dreamsicle; +} + +.cm-s-material .cm-variable-2 { + color: #80CBC4; +} + +.cm-s-material .cm-variable-3, +.cm-s-material .cm-type { + color: $c-laser; +} + +.cm-s-material .cm-builtin { + color: #DECB6B; +} + +.cm-s-material .cm-atom { + color: $c-viridian; +} + +.cm-s-material .cm-number { + color: $c-daisy; +} + +.cm-s-material .cm-def { + color: rgba(233, 237, 237, 1); +} + +.cm-s-material .cm-string { + color: $c-krypton; +} + +.cm-s-material .cm-string-2 { + color: #80CBC4; +} + +.cm-s-material .cm-comment { + color: $g10-wolf; +} + +.cm-s-material .cm-variable { + color: $c-laser; +} + +.cm-s-material .cm-tag { + color: #80CBC4; +} + +.cm-s-material .cm-meta { + color: #80CBC4; +} + +.cm-s-material .cm-attribute { + color: #FFCB6B; +} + +.cm-s-material .cm-property { + color: #80CBAE; +} + +.cm-s-material .cm-qualifier { + color: #DECB6B; +} + +.cm-s-material .cm-variable-3, +.cm-s-material .cm-type { + color: #DECB6B; +} + +.cm-s-material .cm-tag { + color: rgba(255, 83, 112, 1); } -.cm-s-material .CodeMirror-line::selection, .cm-s-material .CodeMirror-line > span::selection, .cm-s-material .CodeMirror-line > span > span::selection { background: rgba(255, 255, 255, 0.10); } -.cm-s-material .CodeMirror-line::-moz-selection, .cm-s-material .CodeMirror-line > span::-moz-selection, .cm-s-material .CodeMirror-line > span > span::-moz-selection { background: rgba(255, 255, 255, 0.10); } -.cm-s-material .CodeMirror-activeline-background { background: rgba(0, 0, 0, 0); } -.cm-s-material .cm-keyword { color: $c-comet; } -.cm-s-material .cm-operator { color: $c-dreamsicle; } -.cm-s-material .cm-variable-2 { color: #80CBC4; } -.cm-s-material .cm-variable-3, .cm-s-material .cm-type { color: $c-laser; } -.cm-s-material .cm-builtin { color: #DECB6B; } -.cm-s-material .cm-atom { color: $c-viridian; } -.cm-s-material .cm-number { color: $c-daisy; } -.cm-s-material .cm-def { color: rgba(233, 237, 237, 1); } -.cm-s-material .cm-string { color: $c-krypton; } -.cm-s-material .cm-string-2 { color: #80CBC4; } -.cm-s-material .cm-comment { color: $g10-wolf; } -.cm-s-material .cm-variable { color: $c-laser; } -.cm-s-material .cm-tag { color: #80CBC4; } -.cm-s-material .cm-meta { color: #80CBC4; } -.cm-s-material .cm-attribute { color: #FFCB6B; } -.cm-s-material .cm-property { color: #80CBAE; } -.cm-s-material .cm-qualifier { color: #DECB6B; } -.cm-s-material .cm-variable-3, .cm-s-material .cm-type { color: #DECB6B; } -.cm-s-material .cm-tag { color: rgba(255, 83, 112, 1); } .cm-s-material .cm-error { color: rgba(255, 255, 255, 1.0); background-color: #EC5F67; } + .cm-s-material .CodeMirror-matchingbracket { text-decoration: underline; color: white !important; } + +// CodeMirror hints +.CodeMirror-hints { + position: absolute; + z-index: 10; + overflow: hidden; + list-style: none; + margin: 0; + padding: 2px; + -webkit-box-shadow: 2px 3px 5px rgba(0, 0, 0, .2); + -moz-box-shadow: 2px 3px 5px rgba(0, 0, 0, .2); + box-shadow: 2px 3px 5px rgba(0, 0, 0, .2); + border-radius: 3px; + border: 1px solid silver; + background: white; + font-size: 90%; + font-family: monospace; + max-height: 20em; + overflow-y: auto; +} + +.CodeMirror-hint { + margin: 0; + padding: 0 4px; + border-radius: 2px; + white-space: pre; + color: black; + cursor: pointer; +} + +li.CodeMirror-hint-active { + background: #08f; + color: white; +} \ No newline at end of file diff --git a/ui/src/style/components/func-node.scss b/ui/src/style/components/func-node.scss deleted file mode 100644 index 82dcfed978..0000000000 --- a/ui/src/style/components/func-node.scss +++ /dev/null @@ -1,39 +0,0 @@ -.func-nodes-container { - display: inline-flex; - flex-direction: column; -} - -.func-node { - display: flex; -} - -.func-node--name { - background: #252b35; - border-radius: $radius-small; - padding: 10px; - width: auto; - display: flex; - color: $ix-text-default; - margin-bottom: $ix-marg-a; - font-family: $ix-text-font; - font-weight: 500; - cursor: pointer; -} - -.func-args { - background: #252b35; - border-radius: $radius-small; - padding: 10px; - margin-bottom: $ix-marg-a; - width: auto; - display: flex; - align-items: stretch; - flex-direction: column; - color: $ix-text-default; - font-family: $ix-text-font; - font-weight: 500; -} - -.func-arg { - display: flex; -} diff --git a/ui/src/style/components/resizer.scss b/ui/src/style/components/resizer.scss index 5c3ffaaaa6..967d1299cd 100644 --- a/ui/src/style/components/resizer.scss +++ b/ui/src/style/components/resizer.scss @@ -14,14 +14,13 @@ $resizer-color: $g5-pepper; $resizer-color-hover: $g8-storm; $resizer-color-active: $c-pool; $resizer-color-kapacitor: $c-rainforest; - .resize--container { overflow: hidden !important; - &.resize--dragging * { @include no-user-select(); } } + .resize--top, .resize--bottom { position: absolute; @@ -41,6 +40,7 @@ $resizer-color-kapacitor: $c-rainforest; Resizable Container Handle ---------------------------------------------- */ + .resizer--handle { top: 60%; left: 0; @@ -51,9 +51,7 @@ $resizer-color-kapacitor: $c-rainforest; z-index: 1; user-select: none; -webkit-user-select: none; - position: absolute; - - // Psuedo element for handle + position: absolute; // Psuedo element for handle &:before { z-index: $resizer-handle-z; color: $resizer-dots; @@ -63,7 +61,7 @@ $resizer-color-kapacitor: $c-rainforest; position: absolute; top: 50%; left: 50%; - transform: translate(-50%,-50%); + transform: translate(-50%, -50%); width: 160px; height: $resizer-handle-width; line-height: $resizer-handle-width; @@ -71,10 +69,8 @@ $resizer-color-kapacitor: $c-rainforest; border-radius: 3px; white-space: nowrap; text-align: center; - transition: - background-color 0.25s ease; - } - // Psuedo element for line + transition: background-color 0.25s ease; + } // Psuedo element for line &:after { z-index: $resizer-line-z; content: ''; @@ -87,12 +83,10 @@ $resizer-color-kapacitor: $c-rainforest; height: $resizer-line-width; background-color: $resizer-color; box-shadow: 0 0 0 transparent; - transition: - background-color 0.19s ease; + transition: background-color 0.19s ease; } &:hover { cursor: ns-resize; - &:before { background-color: $resizer-color-hover; } @@ -103,9 +97,7 @@ $resizer-color-kapacitor: $c-rainforest; &.dragging { &:before, &:after { - transition: - box-shadow 0.3s ease, - background-color 0.3s ease; + transition: box-shadow 0.3s ease, background-color 0.3s ease; background-color: $resizer-color-active; box-shadow: 0 0 $resizer-glow $resizer-color-active; } @@ -113,6 +105,7 @@ $resizer-color-kapacitor: $c-rainforest; } /* Kapacitor Theme */ + .resizer--handle.resizer--malachite.dragging { &:before, &:after { diff --git a/ui/src/style/components/table-graph.scss b/ui/src/style/components/table-graph.scss index 2ed426a422..e5052dfc75 100644 --- a/ui/src/style/components/table-graph.scss +++ b/ui/src/style/components/table-graph.scss @@ -31,7 +31,7 @@ // Highlight &:after { - content: ''; + content: ""; position: absolute; top: 0; left: 0; @@ -80,8 +80,8 @@ padding-right: 17px; &:before { - font-family: 'icomoon'; - content: '\e902'; + font-family: "icomoon"; + content: "\e902"; font-size: 17px; position: absolute; top: 50%; diff --git a/ui/src/style/components/threesizer.scss b/ui/src/style/components/threesizer.scss new file mode 100644 index 0000000000..2bec45e63c --- /dev/null +++ b/ui/src/style/components/threesizer.scss @@ -0,0 +1,183 @@ +/* + Resizable Container with 3 divisions + ------------------------------------------------------------------------------ +*/ + +$threesizer-handle: 30px; + +.threesizer { + width: 100%; + height: 100%; + display: flex; + align-items: stretch; + + &.dragging .threesizer--division { + @include no-user-select(); + pointer-events: none; + } + + &.vertical { + flex-direction: row; + } + + &.horizontal { + flex-direction: column; + } +} + +.threesizer--division { + overflow: hidden; + display: flex; + align-items: stretch; + + transition: height 0.25s ease-in-out, width 0.25s ease-in-out; + + &.dragging { + transition: none; + } + + &.vertical { + flex-direction: row; + } + + &.horizontal { + flex-direction: column; + } +} + +/* Draggable Handle With Title */ +.threesizer--handle { + @include no-user-select(); + background-color: $g4-onyx; + transition: background-color 0.25s ease, color 0.25s ease; + + &.vertical { + border-right: solid 2px $g3-castle; + + &:hover, + &.dragging { + cursor: col-resize; + } + } + + &.horizontal { + border-bottom: solid 2px $g3-castle; + + &:hover, + &.dragging { + cursor: row-resize; + } + } + + &:hover { + &.disabled { + cursor: pointer; + } + + color: $g16-pearl; + background-color: $g5-pepper; + } + + &.dragging { + color: $c-laser; + background-color: $g5-pepper; + } +} + +.threesizer--title { + padding-left: 14px; + position: relative; + font-size: 16px; + font-weight: 500; + white-space: nowrap; + color: $g11-sidewalk; + z-index: 1; + transition: transform 0.25s ease; + + &.vertical { + transform: translate(28px, 14px); + + &.threesizer--collapsed { + transform: translate(0, 3px) rotate(90deg); + } + } +} + +$threesizer-shadow-size: 9px; +$threesizer-z-index: 2; +$threesizer-shadow-start: fade-out($g0-obsidian, 0.82); +$threesizer-shadow-stop: fade-out($g0-obsidian, 1); + +.threesizer--contents { + display: flex; + align-items: stretch; + flex-wrap: nowrap; + position: relative; + + &.horizontal { + flex-direction: row; + } + + &.vertical { + flex-direction: column; + } + + // Bottom Shadow + &.horizontal:after, + &.vertical:after { + content: ''; + position: absolute; + bottom: 0; + right: 0; + z-index: $threesizer-z-index; + } + + &.horizontal:after { + width: 100%; + height: $threesizer-shadow-size; + @include gradient-v($threesizer-shadow-stop, $threesizer-shadow-start); + } + + &.vertical:after { + height: 100%; + width: $threesizer-shadow-size; + @include gradient-h($threesizer-shadow-stop, $threesizer-shadow-start); + } +} + +// Hide bottom shadow on last division +.threesizer--contents.no-shadows:before, +.threesizer--contents.no-shadows:after, +.threesizer--division:last-child .threesizer--contents:after { + content: none; + display: none; +} + +// Header +.threesizer--header { + background-color: $g2-kevlar; + + .horizontal > & { + width: 50px; + border-right: 2px solid $g4-onyx; + } + + .vertical > & { + height: 50px; + border-bottom: 2px solid $g4-onyx; + } +} + +.threesizer--body { + .horizontal > &:only-child { + width: 100%; + } + + .vertical > &:only-child { + height: 100%; + } + + .threesizer--header + & { + flex: 1 0 0; + } +} diff --git a/ui/src/style/components/funcs-button.scss b/ui/src/style/components/time-machine/add-func-button.scss similarity index 57% rename from ui/src/style/components/funcs-button.scss rename to ui/src/style/components/time-machine/add-func-button.scss index 6862571ec3..96a2361d9f 100644 --- a/ui/src/style/components/funcs-button.scss +++ b/ui/src/style/components/time-machine/add-func-button.scss @@ -3,11 +3,38 @@ ---------------------------------------------------------------------------- */ +$ifql-func-selector--gap: 10px; +$ifql-func-selector--height: 30px; + .ifql-func--selector { + display: flex; + align-items: center; position: relative; + + &.open { + z-index: 9999; + } } -.ifql-func--button { +.func-selector--connector { + width: $ifql-func-selector--gap; + height: $ifql-func-selector--height; + position: relative; + + &:after { + content: ''; + position: absolute; + top: 50%; + width: 100%; + height: 4px; + transform: translateY(-50%); + @include gradient-h($g4-onyx, $c-pool); + } +} + +.btn.btn-sm.ifql-func--button { + border-radius: 50%; + float: left; &:focus { box-shadow: 0 0 8px 3px $c-amethyst; } @@ -16,26 +43,28 @@ .ifql-func--autocomplete, .ifql-func--list { position: absolute; - left: 0; width: 166px; } .ifql-func--autocomplete { + left: 0; top: 0; + + .func-selector--connector + & { + left: $ifql-func-selector--gap; + } } .ifql-func--list { - border-radius: 4px; - top: 30px; + left: 0; + border-radius: $radius; + top: $ifql-func-selector--height; padding: 0; margin: 0; @extend %no-user-select; @include gradient-h($c-star, $c-pool); } -.ifql-func--input { -} - .ifql-func--item { height: 28px; line-height: 28px; diff --git a/ui/src/style/components/time-machine/ifql-builder.scss b/ui/src/style/components/time-machine/ifql-builder.scss new file mode 100644 index 0000000000..98ac266542 --- /dev/null +++ b/ui/src/style/components/time-machine/ifql-builder.scss @@ -0,0 +1,207 @@ +$ifql-node-height: 30px; +$ifql-node-tooltip-gap: $ifql-node-height + 4px; +$ifql-node-gap: 5px; +$ifql-node-padding: 10px; +$ifql-arg-min-width: 120px; + +/* + Shared Node styles + ------------------ +*/ +%ifql-node { + height: $ifql-node-height; + border-radius: $radius; + padding: 0 $ifql-node-padding; + font-size: 13px; + font-weight: 600; + position: relative; + background-color: $g4-onyx; + transition: background-color 0.25s ease; + + &:hover { + background-color: $g6-smoke; + } +} + +.body-builder { + padding: 30px; + min-width: 440px; + overflow: hidden; + height: 100%; + width: 100%; + background-color: $g1-raven; +} + +.declaration { + width: 100%; + margin-bottom: 24px; + display: flex; + flex-wrap: nowrap; + + &:last-of-type { + margin-bottom: 0; + } +} + +.variable-string { + @extend %ifql-node; + color: $g11-sidewalk; + line-height: $ifql-node-height; + white-space: nowrap; + @include no-user-select(); +} +.variable-blank { + font-style: italic; +} +.variable-name { + color: $c-pool; +} +.variable-value--string { + color: $c-honeydew +} +.variable-value--boolean { + color: $c-viridian +} +.variable-value--number { + color: $c-neutrino; +} +.variable-value--invalid { + color: $c-dreamsicle; +} + +.func-node { + @extend %ifql-node; + display: flex; + align-items: center; + margin-left: $ifql-node-gap; + + // Connection Line + &:after { + content: ''; + height: 4px; + width: $ifql-node-gap; + background-color: $g4-onyx; + position: absolute; + top: 50%; + left: 0; + transform: translate(-100%, -50%); + } + + &:first-child:after { + content: none; + margin-left: 0; + } +} +.func-node--name, +.func-node--preview { + font-size: 13px; + @include no-user-select(); + white-space: nowrap; + transition: color 0.25s ease; + font-weight: 600; +} + +.func-node--name { + color: $c-comet; + + .func-node:hover & { + color: $c-potassium; + } +} + +.func-node--preview { + color: $g11-sidewalk; + margin-left: 4px; + + .func-node:hover & { + color: $g17-whisper; + } +} + + +.func-node--tooltip, +.variable-name--tooltip { + background-color: $g3-castle; + border-radius: $radius; + padding: 10px; + display: flex; + align-items: stretch; + flex-direction: column; + position: absolute; + top: $ifql-node-tooltip-gap; + left: 0; + z-index: 9999; + box-shadow: 0 0 10px 2px $g2-kevlar; + + // Caret + &:before { + content: ''; + border-width: 9px; + border-style: solid; + border-color: transparent; + border-bottom-color: $g3-castle; + position: absolute; + top: 0; + left: $ifql-node-padding + 3px; + transform: translate(-50%, -100%); + } + + // Invisible block to continue hovering + &:after { + content: ''; + width: 80%; + height: 7px; + position: absolute; + top: -7px; + left: 0; + } +} + +.func-node--delete { + margin-top: 12px; + width: 60px; +} + +.func-arg { + min-width: $ifql-arg-min-width; + display: flex; + flex-wrap: nowrap; + align-items: center; + margin-bottom: 4px; + + &:last-of-type { + margin-bottom: 0; + } +} +.func-arg--label { + white-space: nowrap; + font-size: 13px; + font-weight: 600; + color: $g10-wolf; + padding-right: 8px; + @include no-user-select(); +} +.func-arg--value { + flex: 1 0 0; +} + + +.variable-name--tooltip { + flex-direction: row; + align-items: center; + justify-content: space-between; + flex-wrap: nowrap; +} + +.variable-name--input { + width: 140px; +} + +.variable-name--operator { + width: 20px; + height: 30px; + text-align: center; + line-height: 30px; + font-weight: 600; + @include no-user-select(); +} \ No newline at end of file diff --git a/ui/src/style/components/time-machine.scss b/ui/src/style/components/time-machine/ifql-editor.scss similarity index 67% rename from ui/src/style/components/time-machine.scss rename to ui/src/style/components/time-machine/ifql-editor.scss index b3acd83576..843646300f 100644 --- a/ui/src/style/components/time-machine.scss +++ b/ui/src/style/components/time-machine/ifql-editor.scss @@ -1,3 +1,8 @@ +/* + IFQL Code Mirror Editor + ---------------------------------------------------------------------------- +*/ + .time-machine-container { display: flex; height: 90%; diff --git a/ui/src/style/components/time-machine/ifql-explorer.scss b/ui/src/style/components/time-machine/ifql-explorer.scss new file mode 100644 index 0000000000..502621aa32 --- /dev/null +++ b/ui/src/style/components/time-machine/ifql-explorer.scss @@ -0,0 +1,185 @@ +/* + IFQL Schema Explorer -- Tree View + ---------------------------------------------------------------------------- +*/ + +$ifql-tree-indent: 26px; +$ifql-tree-line: 2px; + +.ifql-schema-explorer { + width: 100%; + height: 100%; + background-color: $g2-kevlar; + min-width: 200px; +} + +.ifql-schema-tree { + position: relative; + display: flex; + flex-direction: column; + align-items: stretch; + padding-left: 0; + + > .ifql-schema-tree { + padding-left: $ifql-tree-indent; + } +} + +.ifql-schema-tree__empty { + height: $ifql-tree-indent; + display: flex; + align-items: center; + padding: 0 11px; + font-size: 12px; + font-weight: 600; + color: $g8-storm; + font-style: italic; +} + +.ifql-schema-item-toggle { + width: $ifql-tree-indent; + height: $ifql-tree-indent; + position: relative; + + // Plus Sign + &:before, + &:after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background-color: $g11-sidewalk; + width: $ifql-tree-indent / 3; + height: $ifql-tree-line; + transition: transform 0.25s ease, background-color 0.25s ease; + } + // Vertical Line + &:after { + transform: translate(-50%, -50%) rotate(90deg); + } +} + +.ifql-schema-item { + @include no-user-select(); + position: relative; + height: $ifql-tree-indent; + display: flex; + align-items: center; + padding: 0 11px; + padding-left: 0; + font-size: 12px; + font-weight: 600; + color: $g11-sidewalk; + white-space: nowrap; + transition: color 0.25s ease, background-color 0.25s ease; + + > span.icon { + position: absolute; + top: 50%; + left: $ifql-tree-indent / 2; + transform: translate(-50%, -50%); + } + + &:hover { + color: $g17-whisper; + cursor: pointer; + background-color: $g4-onyx; + + .ifql-schema-item-toggle:before, + .ifql-schema-item-toggle:after { + background-color: $g17-whisper; + } + } + + .expanded > & { + color: $c-pool; + + .ifql-schema-item-toggle:before, + .ifql-schema-item-toggle:after { + background-color: $c-pool; + } + .ifql-schema-item-toggle:before { + transform: translate(-50%, -50%) rotate(-90deg); + width: $ifql-tree-line; + } + .ifql-schema-item-toggle:after { + transform: translate(-50%, -50%) rotate(0deg); + } + + &:hover { + color: $c-laser; + + .ifql-schema-item-toggle:before, + .ifql-schema-item-toggle:after { + background-color: $c-laser; + } + } + } + + &.readonly, + &.readonly:hover { + padding-left: $ifql-tree-indent + 8px; + background-color: transparent; + color: $g11-sidewalk; + cursor: default; + } +} + +/* Tree Node Lines */ +.ifql-tree-node:before, +.ifql-tree-node:after { + content: ''; + background-color: $g4-onyx; + position: absolute; +} + +// Vertical Line +.ifql-tree-node:before { + top: 0; + left: $ifql-tree-indent / 2; + width: $ifql-tree-line; + height: 100%; +} +.ifql-tree-node:last-child:before { + height: $ifql-tree-indent / 2; +} + +// Horizontal Line +.ifql-tree-node:after { + top: $ifql-tree-indent / 2; + left: $ifql-tree-indent / 2; + width: $ifql-tree-indent / 2; + height: $ifql-tree-line; +} + +/* + Controls + ---------------------------------------------------------------------------- +*/ +.ifql-schema--controls { + padding: 11px; + display: flex; + align-items: center; + justify-content: space-between; +} + +.ifql-schema--filter { + flex: 1 0 0; + margin-right: 4px; +} + + + +// Hints +.ifql-schema-type { + color: $g11-sidewalk; + display: inline-block; + margin-left: 8px; + opacity: 0; + transition: opacity 0.25s ease; + + .ifql-schema-item:hover & { + opacity: 1; + } +} diff --git a/ui/src/style/components/time-machine/visualization.scss b/ui/src/style/components/time-machine/visualization.scss new file mode 100644 index 0000000000..122d54e10f --- /dev/null +++ b/ui/src/style/components/time-machine/visualization.scss @@ -0,0 +1,48 @@ +/* + Time Machine Visualization + ---------------------------------------------------------------------------- +*/ + +.time-machine-visualization { + display: flex; + align-content: center; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + @include gradient-v($g2-kevlar, $g0-obsidian); +} + +.time-machine--graph { + width: calc(100% - 60px); + height: calc(100% - 60px); + background-color: $g3-castle; + border-radius: $radius; + display: flex; + flex-direction: column; + align-items: stretch; + flex-wrap: nowrap; +} + +.time-machine--graph-header { + height: 56px; + padding: 0 16px; + display: flex; + align-items: center; + justify-content: center +} + +.time-machine--graph-header .nav.nav-tablist { + width: 180px; + + li { + justify-content: center; + flex: 1 0 0; + white-space: nowrap; + } +} + +.time-machine--graph-body { + padding: 0 16px 8px 16px; + flex: 1 0 0; +} \ No newline at end of file diff --git a/ui/src/style/fonts/icomoon.eot b/ui/src/style/fonts/icomoon.eot index d4aca11f4d..6a466d0f50 100755 Binary files a/ui/src/style/fonts/icomoon.eot and b/ui/src/style/fonts/icomoon.eot differ diff --git a/ui/src/style/fonts/icomoon.svg b/ui/src/style/fonts/icomoon.svg index fbf49ee755..7673d95bd5 100755 --- a/ui/src/style/fonts/icomoon.svg +++ b/ui/src/style/fonts/icomoon.svg @@ -23,8 +23,10 @@ + + diff --git a/ui/src/style/fonts/icomoon.ttf b/ui/src/style/fonts/icomoon.ttf index 32fe638b86..6baf37bbdb 100755 Binary files a/ui/src/style/fonts/icomoon.ttf and b/ui/src/style/fonts/icomoon.ttf differ diff --git a/ui/src/style/fonts/icomoon.woff b/ui/src/style/fonts/icomoon.woff index 8e1ef4e99d..b69d8bf5cc 100755 Binary files a/ui/src/style/fonts/icomoon.woff and b/ui/src/style/fonts/icomoon.woff differ diff --git a/ui/src/style/fonts/icomoon.woff2 b/ui/src/style/fonts/icomoon.woff2 index be51fbda5a..42b5a23e32 100755 Binary files a/ui/src/style/fonts/icomoon.woff2 and b/ui/src/style/fonts/icomoon.woff2 differ diff --git a/ui/src/style/fonts/icon-font.scss b/ui/src/style/fonts/icon-font.scss index 070056c590..4ffc51a29a 100644 --- a/ui/src/style/fonts/icon-font.scss +++ b/ui/src/style/fonts/icon-font.scss @@ -30,6 +30,8 @@ -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; + &.collapse:before {content: "\e90f";} + &.okta:before {content: "\e912";} &.user-remove:before {content: "\e904";} &.user-add:before {content: "\e907";} &.group:before {content: "\e908";} diff --git a/ui/src/style/pages/dashboards.scss b/ui/src/style/pages/dashboards.scss index afcd69a0c6..3d3e4d080b 100644 --- a/ui/src/style/pages/dashboards.scss +++ b/ui/src/style/pages/dashboards.scss @@ -2,14 +2,15 @@ Variables ------------------------------------------------------ */ + $dash-graph-heading: 30px; $dash-graph-heading-context: $dash-graph-heading - 8px; $dash-graph-options-arrow: 8px; - /* Animations ------------------------------------------------------ */ + @keyframes refreshingSpinnerA { 0% { transform: translate(-50%, -50%) scale(1.75); @@ -25,6 +26,7 @@ $dash-graph-options-arrow: 8px; transform: translate(-50%, -50%) scale(1, 1); } } + @keyframes refreshingSpinnerB { 0% { transform: translate(-50%, -50%) scale(1, 1); @@ -40,6 +42,7 @@ $dash-graph-options-arrow: 8px; transform: translate(-50%, -50%) scale(1, 1); } } + @keyframes refreshingSpinnerC { 0% { transform: translate(-50%, -50%) scale(1, 1); @@ -60,6 +63,7 @@ $dash-graph-options-arrow: 8px; Dashboard Index Page ------------------------------------------------------ */ + .dashboards-page--actions { display: flex; align-items: center; @@ -69,6 +73,7 @@ $dash-graph-options-arrow: 8px; Default Dashboard Mode ------------------------------------------------------ */ + .cell-shell { background-color: $g3-castle; border-radius: $radius; @@ -89,6 +94,7 @@ $dash-graph-options-arrow: 8px; left: 0; } } + .dash-graph { position: absolute; width: 100%; @@ -96,6 +102,7 @@ $dash-graph-options-arrow: 8px; top: 0; left: 0; } + .dash-graph--container { user-select: none !important; -o-user-select: none !important; @@ -108,7 +115,6 @@ $dash-graph-options-arrow: 8px; top: $dash-graph-heading; left: 0; padding: 0; - .dygraph { position: absolute; left: 0; @@ -126,6 +132,7 @@ $dash-graph-options-arrow: 8px; top: (-$dash-graph-heading + 5px) !important; } } + .dash-graph--heading { user-select: none !important; -o-user-select: none !important; @@ -151,6 +158,7 @@ $dash-graph-options-arrow: 8px; background-color: $g5-pepper; } } + .dash-graph--name { font-size: 12px; font-weight: 600; @@ -165,23 +173,24 @@ $dash-graph-options-arrow: 8px; padding-left: 10px; transition: color 0.25s ease, background-color 0.25s ease, border-color 0.25s ease; - &:only-child { width: 100%; } } + .dash-graph--name.dash-graph--name__default { font-style: italic; } + .dash-graph--draggable { cursor: move !important; } + .dash-graph--custom-indicators { height: 24px; border-radius: 3px; display: flex; cursor: default; - > .custom-indicator, > .source-indicator { font-size: 10px; @@ -196,13 +205,13 @@ $dash-graph-options-arrow: 8px; } > .source-indicator { height: 24px; - > .icon { font-size: 12px; margin: 0; } } } + .dash-graph-context { z-index: 2; position: absolute; @@ -213,12 +222,15 @@ $dash-graph-options-arrow: 8px; align-items: center; flex-wrap: nowrap; } + .dash-graph-context.dash-graph-context__open { z-index: 20; } + .dash-graph-context--buttons { display: flex; } + .dash-graph-context--button { width: 24px; height: 24px; @@ -228,7 +240,6 @@ $dash-graph-options-arrow: 8px; color: $g11-sidewalk; margin-right: 2px; transition: color 0.25s ease, background-color 0.25s ease; - &:hover, &.active { cursor: pointer; @@ -238,7 +249,6 @@ $dash-graph-options-arrow: 8px; &:last-child { margin-right: 0; } - > .icon { position: absolute; top: 50%; @@ -250,6 +260,7 @@ $dash-graph-options-arrow: 8px; z-index: 20; } } + .dash-graph-context--menu, .dash-graph-context--menu.default { z-index: 3; @@ -263,7 +274,6 @@ $dash-graph-options-arrow: 8px; flex-direction: column; align-items: stretch; justify-content: center; - &:before { position: absolute; content: ''; @@ -274,7 +284,6 @@ $dash-graph-options-arrow: 8px; transform: translate(-50%, -100%); transition: border-color 0.25s ease; } - .dash-graph-context--menu-item { @include no-user-select(); white-space: nowrap; @@ -285,7 +294,6 @@ $dash-graph-options-arrow: 8px; padding: 0 10px; color: $g20-white; transition: background-color 0.25s ease; - &:first-child { border-top-left-radius: 3px; border-top-right-radius: 3px; @@ -298,7 +306,6 @@ $dash-graph-options-arrow: 8px; background-color: $g8-storm; cursor: pointer; } - &.disabled, &.disabled:hover { cursor: default; @@ -318,6 +325,7 @@ $dash-graph-options-arrow: 8px; background-color: $c-pool; } } + .dash-graph-context--menu.warning { background-color: $c-star; &:before { @@ -327,6 +335,7 @@ $dash-graph-options-arrow: 8px; background-color: $c-comet; } } + .dash-graph-context--menu.success { background-color: $c-rainforest; &:before { @@ -336,6 +345,7 @@ $dash-graph-options-arrow: 8px; background-color: $c-honeydew; } } + .dash-graph-context--menu.danger { background-color: $c-curacao; &:before { @@ -347,6 +357,7 @@ $dash-graph-options-arrow: 8px; } /* Presentation Mode */ + .presentation-mode { .dash-graph-context { display: none; @@ -364,7 +375,6 @@ $dash-graph-options-arrow: 8px; transform: translateX(50%); width: 16px; height: 18px; - > div { width: 4px; height: 4px; @@ -374,7 +384,6 @@ $dash-graph-options-arrow: 8px; top: 50%; transform: translate(-50%, -50%); } - div:nth-child(1) { left: 0; animation: refreshingSpinnerA 0.8s cubic-bezier(0.645, 0.045, 0.355, 1) @@ -396,13 +405,15 @@ $dash-graph-options-arrow: 8px; Dashboard Edit Mode ------------------------------------------------------ */ + .react-grid-placeholder { - @include gradient-diag-down($c-pool,$c-comet); + @include gradient-diag-down($c-pool, $c-comet); border: 0 !important; opacity: 0.3; z-index: 2; border-radius: $radius !important; } + .react-grid-item { &.resizing { background-color: fade-out($g3-castle, 0.09); @@ -413,7 +424,6 @@ $dash-graph-options-arrow: 8px; border-image-width: 2px; border-image-source: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAYAAABw4pVUAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyhpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMDY3IDc5LjE1Nzc0NywgMjAxNS8wMy8zMC0yMzo0MDo0MiAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTUgKE1hY2ludG9zaCkiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6MTg0NjVDRkVGMEVFMTFFNkE0QjVFRTJGNEI1ODc0RDMiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6MTg0NjVDRkZGMEVFMTFFNkE0QjVFRTJGNEI1ODc0RDMiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDoxODQ2NUNGQ0YwRUUxMUU2QTRCNUVFMkY0QjU4NzREMyIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDoxODQ2NUNGREYwRUUxMUU2QTRCNUVFMkY0QjU4NzREMyIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PpeetfIAAAMnSURBVHja7N1PatwwFMfxJ5NlKT1DIfQKWZfSA/Q0hexDL9McoOQAPUKglwhp6dZ9Ho/HfyTZs6l+b/E1GDm27IH5oH9Pyji9//7XfLtNZt88/eT722TzlvrFseXHaXFmypuO8vd5nmW6nyeNefrKfZv7i9f75blU/NzafXvns2dV7tl8zqsnT55+9f3Xjf/xwQ9+evou+xLB+N8Ydi4AX3z/6PnvOj94AEOGMV/rB4P00J2rKTC0GNOTPne0GWEwhv1NB0YYjNPWgREHI00gYMTAOIGAEQdjuKcDIw7GXGWBEQJjrLLACIORrFBlgaHDsG2VBYYWY1VlgaHHSH3WqIOhxLB1ow6GGmPRqIMRAeMMAkYUDFuGTsDQYwxP6MCIg1Hp9oKhwih0e8FQYthuLAuM5hj1WBYYEoxUjGWBIcOwrFEHQ4qxLiFgyDFOvSww4mCM8yFghMEoDgzB0GGk2owhGBoMq5UQMDQYxRIChg4ja0PA0GLYMrgIhh7jUkLAiIExV1lghMA4GBiC0RrjNIULRhyMysAQDBVGYWAIhhJjM6cOhhpjUULAiIAxr1wEIwTGPDAEIwTGWGWBEQajHu0FQ4JRjvaCIcPIo71gSDHW0V4w5Bj5SB0MKUZxoRwYOoxsPgQMLcZqPgQMPUaxUQdDh2HVcQgYEoxUHIeAIcPIqywwpBjrKgsMOcb8f+pghMDIwu9gaDFWI3Uw9Bg2N+pgRMA497LAiIJRXf0OhgajuPodDB3G1dFeMNpgXBXtBaMdxmG0F4y2GLvRXjDaY2wGhmCoMawU7QVDh5G20V4wtBjzwBCMEBiXVx6BEQPjsJcFRluM3V4WGO0xqr0sMDQYVuplgaHDWL1YEgw9hi17WWDoMVJ1ChcMCYYVp3DBkGFUl5KCocGw6deAwIiBYUfBRTDaYmTdXjC0GFYLLoKhwSj+cAAYOgzbBhfB0GKsgotg6DGuWrkIRjuMudsLRgiMsQ0BIwzG5ZVHYMTAmKqsVzBiYPj2Z+j2PoERAmM4/2MoIfe+v4Ahx3jx5H4AefYLd37q0Y9/g9EcY/jOHz11A3v+J8AA9wisahRCWTQAAAAASUVORK5CYII=); z-index: 3; - & > .react-resizable-handle { &:before, &:after { @@ -433,7 +443,6 @@ $dash-graph-options-arrow: 8px; &:hover { cursor: move; } - .dash-graph--heading { background-color: $g5-pepper; cursor: move; @@ -442,7 +451,6 @@ $dash-graph-options-arrow: 8px; & > .react-resizable-handle { background-image: none; cursor: nwse-resize; - &:before, &:after { content: ''; @@ -477,28 +485,29 @@ $dash-graph-options-arrow: 8px; Dashboard Empty State ------------------------------------------------------ */ -@import '../components/dashboard-empty'; +@import '../components/dashboard-empty'; /* Template Control Bar ------------------------------------------------------ */ -@import '../components/template-control-bar'; +@import '../components/template-control-bar'; /* Cell Editor Overlay ------------------------------------------------------ */ -@import 'cell-editor-overlay'; +@import 'cell-editor-overlay'; /* Template Variables Manager ------------------------------------------------------ */ -@import '../components/template-variables-manager'; +@import '../components/template-variables-manager'; /* Write Data Form ------------------------------------------------------ */ + @import '../components/write-data-form'; diff --git a/ui/src/style/pages/time-machine.scss b/ui/src/style/pages/time-machine.scss new file mode 100644 index 0000000000..2b6c49f5b7 --- /dev/null +++ b/ui/src/style/pages/time-machine.scss @@ -0,0 +1,10 @@ +/* + Styles for IFQL Builder aka TIME MACHINE aka DELOREAN + ---------------------------------------------------------------------------- +*/ + +@import '../components/time-machine/ifql-editor'; +@import '../components/time-machine/ifql-builder'; +@import '../components/time-machine/ifql-explorer'; +@import '../components/time-machine/visualization'; +@import '../components/time-machine/add-func-button'; \ No newline at end of file diff --git a/ui/src/types/ifql.ts b/ui/src/types/ifql.ts index 65bd406f93..e84e4d2c07 100644 --- a/ui/src/types/ifql.ts +++ b/ui/src/types/ifql.ts @@ -51,7 +51,7 @@ export interface Func { type Value = string | boolean -interface Arg { +export interface Arg { key: string value: Value type: string diff --git a/ui/src/utils/groupByTimeSeriesTransform.js b/ui/src/utils/groupByTimeSeriesTransform.js index 3df7f0d52e..f3ae47bd04 100644 --- a/ui/src/utils/groupByTimeSeriesTransform.js +++ b/ui/src/utils/groupByTimeSeriesTransform.js @@ -166,21 +166,31 @@ const insertGroupByValues = ( sortedLabels ) => { const dashArray = Array(sortedLabels.length).fill('-') - let timeSeries = [] - forEach(serieses, (s, sind) => { - if (s.isGroupBy) { - forEach(s.values, vs => { - const tsRow = {time: vs[0], values: clone(dashArray)} - forEach(vs.slice(1), (v, i) => { - const label = seriesLabels[sind][i].label - tsRow.values[ - labelsToValueIndex[label + s.responseIndex + s.seriesIndex] - ] = v - }) - timeSeries = [...timeSeries, tsRow] - }) + const timeSeries = [] + + for (let x = 0; x < serieses.length; x++) { + const s = serieses[x] + if (!s.isGroupBy) { + continue } - }) + + for (let i = 0; i < s.values.length; i++) { + const vs = s.values[i] + const tsRow = {time: vs[0], values: clone(dashArray)} + + const vss = vs.slice(1) + for (let j = 0; j < vss.length; j++) { + const v = vss[j] + const label = seriesLabels[x][j].label + + tsRow.values[ + labelsToValueIndex[label + s.responseIndex + s.seriesIndex] + ] = v + } + + timeSeries.push(tsRow) + } + } return timeSeries } @@ -245,7 +255,6 @@ const constructTimeSeries = (serieses, cells, sortedLabels, seriesLabels) => { export const groupByTimeSeriesTransform = (raw, isTable) => { const results = constructResults(raw, isTable) const serieses = constructSerieses(results) - const {cells, sortedLabels, seriesLabels} = constructCells(serieses) const sortedTimeSeries = constructTimeSeries( diff --git a/ui/test/shared/parsing/dataToCSV.test.js b/ui/test/shared/parsing/dataToCSV.test.js new file mode 100644 index 0000000000..d46e620cf1 --- /dev/null +++ b/ui/test/shared/parsing/dataToCSV.test.js @@ -0,0 +1,46 @@ +import {dataToCSV, formatDate} from 'shared/parsing/dataToCSV' +import moment from 'moment' + +describe('formatDate', () => { + it('converts timestamp to an excel compatible date string', () => { + const timestamp = 1000000000000 + const result = formatDate(timestamp) + expect(moment(result, 'M/D/YYYY h:mm:ss.SSSSSSSSS A').valueOf()).toBe( + timestamp + ) + }) +}) + +describe('dataToCSV', () => { + it('parses data, an array of arrays, to a csv string', () => { + const data = [[1, 2], [3, 4], [5, 6], [7, 8]] + const returned = dataToCSV(data) + const expected = `1,2\n3,4\n5,6\n7,8` + + expect(returned).toEqual(expected) + }) + + it('converts values to dates if title of first column is time.', () => { + const data = [ + ['time', 'something'], + [1505262600000, 0.06163066773148772], + [1505264400000, 2.616484718180463], + [1505266200000, 1.6174323943535571], + ] + const returned = dataToCSV(data) + const expected = `date,something\n${formatDate( + 1505262600000 + )},0.06163066773148772\n${formatDate( + 1505264400000 + )},2.616484718180463\n${formatDate(1505266200000)},1.6174323943535571` + + expect(returned).toEqual(expected) + }) + + it('returns an empty string if data is empty', () => { + const data = [[]] + const returned = dataToCSV(data) + const expected = '' + expect(returned).toEqual(expected) + }) +}) diff --git a/ui/test/shared/parsing/resultsToCSV.test.js b/ui/test/shared/parsing/resultsToCSV.test.js deleted file mode 100644 index fdc84ace22..0000000000 --- a/ui/test/shared/parsing/resultsToCSV.test.js +++ /dev/null @@ -1,105 +0,0 @@ -import { - resultsToCSV, - formatDate, - dashboardtoCSV, -} from 'shared/parsing/resultsToCSV' -import moment from 'moment' - -describe('formatDate', () => { - it('converts timestamp to an excel compatible date string', () => { - const timestamp = 1000000000000 - const result = formatDate(timestamp) - expect(moment(result, 'M/D/YYYY h:mm:ss.SSSSSSSSS A').valueOf()).toBe( - timestamp - ) - }) -}) - -describe('resultsToCSV', () => { - it('parses results, a time series data structure, to an object with name and CSVString keys', () => { - const results = [ - { - statement_id: 0, - series: [ - { - name: 'procstat', - columns: ['time', 'mean_cpu_usage'], - values: [ - [1505262600000, 0.06163066773148772], - [1505264400000, 2.616484718180463], - [1505266200000, 1.6174323943535571], - ], - }, - ], - }, - ] - const response = resultsToCSV(results) - const expected = { - flag: 'ok', - name: 'procstat', - CSVString: `date,mean_cpu_usage\n${formatDate( - 1505262600000 - )},0.06163066773148772\n${formatDate( - 1505264400000 - )},2.616484718180463\n${formatDate(1505266200000)},1.6174323943535571`, - } - expect(Object.keys(response).sort()).toEqual( - ['flag', 'name', 'CSVString'].sort() - ) - expect(response.flag).toBe(expected.flag) - expect(response.name).toBe(expected.name) - expect(response.CSVString).toBe(expected.CSVString) - }) -}) - -describe('dashboardtoCSV', () => { - it('parses the array of timeseries data displayed by the dashboard cell to a CSVstring for download', () => { - const data = [ - { - results: [ - { - statement_id: 0, - series: [ - { - name: 'procstat', - columns: ['time', 'mean_cpu_usage'], - values: [ - [1505262600000, 0.06163066773148772], - [1505264400000, 2.616484718180463], - [1505266200000, 1.6174323943535571], - ], - }, - ], - }, - ], - }, - { - results: [ - { - statement_id: 0, - series: [ - { - name: 'procstat', - columns: ['not-time', 'mean_cpu_usage'], - values: [ - [1505262600000, 0.06163066773148772], - [1505264400000, 2.616484718180463], - [1505266200000, 1.6174323943535571], - ], - }, - ], - }, - ], - }, - ] - const result = dashboardtoCSV(data) - const expected = `time,mean_cpu_usage,not-time,mean_cpu_usage\n${formatDate( - 1505262600000 - )},0.06163066773148772,1505262600000,0.06163066773148772\n${formatDate( - 1505264400000 - )},2.616484718180463,1505264400000,2.616484718180463\n${formatDate( - 1505266200000 - )},1.6174323943535571,1505266200000,1.6174323943535571` - expect(result).toBe(expected) - }) -}) diff --git a/ui/webpack/dev.config.js b/ui/webpack/dev.config.js index 7d575003c9..3ebbccb851 100644 --- a/ui/webpack/dev.config.js +++ b/ui/webpack/dev.config.js @@ -36,7 +36,7 @@ module.exports = { }, watch: true, cache: true, - devtool: 'source-map', + devtool: 'cheap-eval-source-map', entry: { app: path.resolve(__dirname, '..', 'src', 'index.tsx'), },