From 86a0e7175d757f85b8aa198b37d8971d2602f146 Mon Sep 17 00:00:00 2001 From: deniz kusefoglu Date: Mon, 11 Sep 2017 13:27:46 -0700 Subject: [PATCH 1/9] Add csv download button to data explorer visualization header --- ui/src/data_explorer/components/VisHeader.js | 49 ++++-- ui/src/data_explorer/components/VisView.js | 7 +- .../data_explorer/components/Visualization.js | 7 +- ui/src/external/download.js | 166 ++++++++++++++++++ ui/src/shared/parsing/resultsToCSV.js | 13 ++ ui/src/style/unsorted.scss | 12 ++ 6 files changed, 234 insertions(+), 20 deletions(-) create mode 100644 ui/src/external/download.js create mode 100644 ui/src/shared/parsing/resultsToCSV.js diff --git a/ui/src/data_explorer/components/VisHeader.js b/ui/src/data_explorer/components/VisHeader.js index 450b0d1740..cb7005b6dd 100644 --- a/ui/src/data_explorer/components/VisHeader.js +++ b/ui/src/data_explorer/components/VisHeader.js @@ -2,34 +2,55 @@ import React, {PropTypes} from 'react' import classnames from 'classnames' import _ from 'lodash' -const VisHeader = ({views, view, onToggleView, name}) => +import {fetchTimeSeriesAsync} from 'shared/actions/timeSeries' +import resultsToCSV from 'src/shared/parsing/resultsToCSV.js' +import download from 'src/external/download.js' + +const getCSV = query => async () => { + try { + const {results} = await fetchTimeSeriesAsync({source: query.host, query}) + const {name, CSVString} = resultsToCSV(results) + download(CSVString, `${name}.csv`, 'text/plain') + } catch (error) { + console.error(error) + } +} + +const VisHeader = ({views, view, onToggleView, name, query}) =>
{views.length - ? + ?
+
    + {views.map(v => +
  • + {_.upperFirst(v)} +
  • + )} +
+
+ + .csv +
+
: null}
{name}
-const {arrayOf, func, string} = PropTypes +const {arrayOf, func, shape, string} = PropTypes VisHeader.propTypes = { views: arrayOf(string).isRequired, view: string.isRequired, onToggleView: func.isRequired, name: string.isRequired, + query: shape().isRequired, } export default VisHeader diff --git a/ui/src/data_explorer/components/VisView.js b/ui/src/data_explorer/components/VisView.js index 1f9ddcdb12..c6c9714a89 100644 --- a/ui/src/data_explorer/components/VisView.js +++ b/ui/src/data_explorer/components/VisView.js @@ -6,19 +6,15 @@ import RefreshingGraph from 'shared/components/RefreshingGraph' const VisView = ({ axes, view, + query, queries, cellType, templates, autoRefresh, heightPixels, editQueryStatus, - activeQueryIndex, resizerBottomHeight, }) => { - const activeQuery = queries[activeQueryIndex] - const defaultQuery = queries[0] - const query = activeQuery || defaultQuery - if (view === 'table') { if (!query) { return ( @@ -55,6 +51,7 @@ const {arrayOf, func, number, shape, string} = PropTypes VisView.propTypes = { view: string.isRequired, axes: shape(), + query: shape().isRequired, queries: arrayOf(shape()).isRequired, cellType: string, templates: arrayOf(shape()), diff --git a/ui/src/data_explorer/components/Visualization.js b/ui/src/data_explorer/components/Visualization.js index c4bba5e649..725cde9f22 100644 --- a/ui/src/data_explorer/components/Visualization.js +++ b/ui/src/data_explorer/components/Visualization.js @@ -73,6 +73,10 @@ class Visualization extends Component { return {host: [proxy], text: s.text, id: s.id, queryConfig: s.queryConfig} }) + const activeQuery = queries[activeQueryIndex] + const defaultQuery = queries[0] + const query = activeQuery || defaultQuery + return (
diff --git a/ui/src/external/download.js b/ui/src/external/download.js new file mode 100644 index 0000000000..ed16a881ed --- /dev/null +++ b/ui/src/external/download.js @@ -0,0 +1,166 @@ +// download.js v4.2, by dandavis; 2008-2016. [CCBY2] see http://danml.com/download.html for tests/usage +// v1 landed a FF+Chrome compat way of downloading strings to local un-named files, upgraded to use a hidden frame and optional mime +// v2 added named files via a[download], msSaveBlob, IE (10+) support, and window.URL support for larger+faster saves than dataURLs +// v3 added dataURL and Blob Input, bind-toggle arity, and legacy dataURL fallback was improved with force-download mime and base64 support. 3.1 improved safari handling. +// v4 adds AMD/UMD, commonJS, and plain browser support +// v4.1 adds url download capability via solo URL argument (same domain/CORS only) +// v4.2 adds semantic variable names, long (over 2MB) dataURL support, and hidden by default temp anchors +// https://github.com/rndme/download + +const download = (data, strFileName, strMimeType) => { + let self = window, // this script is only for browsers anyway... + defaultMime = 'application/octet-stream', // this default mime also triggers iframe downloads + mimeType = strMimeType || defaultMime, + payload = data, + url = !strFileName && !strMimeType && payload, + anchor = document.createElement('a'), + toString = function(a) { + return String(a) + }, + myBlob = self.Blob || self.MozBlob || self.WebKitBlob || toString, + fileName = strFileName || 'download', + blob, + reader + myBlob = myBlob.call ? myBlob.bind(self) : Blob + + if (String(this) === 'true') { + // reverse arguments, allowing download.bind(true, "text/xml", "export.xml") to act as a callback + payload = [payload, mimeType] + mimeType = payload[0] + payload = payload[1] + } + + if (url && url.length < 2048) { + // if no filename and no mime, assume a url was passed as the only argument + fileName = url.split('/').pop().split('?')[0] + anchor.href = url // assign href prop to temp anchor + if (anchor.href.indexOf(url) !== -1) { + // if the browser determines that it's a potentially valid url path: + const ajax = new XMLHttpRequest() + ajax.open('GET', url, true) + ajax.responseType = 'blob' + ajax.onload = function(e) { + download(e.target.response, fileName, defaultMime) + } + setTimeout(function() { + ajax.send() + }, 0) // allows setting custom ajax headers using the return: + return ajax + } // end if valid url? + } // end if url? + + // go ahead and download dataURLs right away + if (/^data\:[\w+\-]+\/[\w+\-]+[,;]/.test(payload)) { + if (payload.length > 1024 * 1024 * 1.999 && myBlob !== toString) { + payload = dataUrlToBlob(payload) + mimeType = payload.type || defaultMime + } else { + return navigator.msSaveBlob // IE10 can't do a[download], only Blobs: + ? navigator.msSaveBlob(dataUrlToBlob(payload), fileName) + : saver(payload) // everyone else can save dataURLs un-processed + } + } // end if dataURL passed? + + blob = + payload instanceof myBlob + ? payload + : new myBlob([payload], {type: mimeType}) + + function dataUrlToBlob(strUrl) { + let parts = strUrl.split(/[:;,]/), + type = parts[1], + decoder = parts[2] == 'base64' ? atob : decodeURIComponent, + binData = decoder(parts.pop()), + mx = binData.length, + i = 0, + uiArr = new Uint8Array(mx) + + for (i; i < mx; ++i) { + uiArr[i] = binData.charCodeAt(i) + } + + return new myBlob([uiArr], {type}) + } + + function saver(url, winMode) { + if ('download' in anchor) { + // html5 A[download] + anchor.href = url + anchor.setAttribute('download', fileName) + anchor.className = 'download-js-link' + anchor.innerHTML = 'downloading...' + anchor.style.display = 'none' + document.body.appendChild(anchor) + setTimeout(function() { + anchor.click() + document.body.removeChild(anchor) + if (winMode === true) { + setTimeout(function() { + self.URL.revokeObjectURL(anchor.href) + }, 250) + } + }, 66) + return true + } + + // handle non-a[download] safari as best we can: + if ( + /(Version)\/(\d+)\.(\d+)(?:\.(\d+))?.*Safari\//.test(navigator.userAgent) + ) { + url = url.replace(/^data:([\w\/\-\+]+)/, defaultMime) + if (!window.open(url)) { + // popup blocked, offer direct download: + if ( + confirm( + 'Displaying New Document\n\nUse Save As... to download, then click back to return to this page.' + ) + ) { + location.href = url + } + } + return true + } + + // do iframe dataURL download (old ch+FF): + const f = document.createElement('iframe') + document.body.appendChild(f) + + if (!winMode) { + // force a mime that will download: + url = `data:${url.replace(/^data:([\w\/\-\+]+)/, defaultMime)}` + } + f.src = url + setTimeout(function() { + document.body.removeChild(f) + }, 333) + } // end saver + + if (navigator.msSaveBlob) { + // IE10+ : (has Blob, but not a[download] or URL) + return navigator.msSaveBlob(blob, fileName) + } + + if (self.URL) { + // simple fast and modern way using Blob and URL: + saver(self.URL.createObjectURL(blob), true) + } else { + // handle non-Blob()+non-URL browsers: + if (typeof blob === 'string' || blob.constructor === toString) { + try { + return saver(`data:${mimeType};base64,${self.btoa(blob)}`) + } catch (y) { + return saver(`data:${mimeType},${encodeURIComponent(blob)}`) + } + } + + // Blob but not URL support: + reader = new FileReader() + reader.onload = function(e) { + saver(this.result) + } + reader.readAsDataURL(blob) + } + return true +} /* end download() */ + +export default download diff --git a/ui/src/shared/parsing/resultsToCSV.js b/ui/src/shared/parsing/resultsToCSV.js new file mode 100644 index 0000000000..3e89bbee2c --- /dev/null +++ b/ui/src/shared/parsing/resultsToCSV.js @@ -0,0 +1,13 @@ +import _ from 'lodash' + +const resultsToCSV = results => { + const name = _.get(results, ['0', 'series', '0', 'name']) + const columns = _.get(results, ['0', 'series', '0', 'columns']) + const values = _.get(results, ['0', 'series', '0', 'values']) + + const CSVString = `\"${_.join(columns, '","')}\"\n${values + .map(arr => `${arr[0].toString()},${arr[1].toString()}`) + .join('\n')}` + return {name, CSVString} +} +export default resultsToCSV diff --git a/ui/src/style/unsorted.scss b/ui/src/style/unsorted.scss index df1245aaa2..41cd865de9 100644 --- a/ui/src/style/unsorted.scss +++ b/ui/src/style/unsorted.scss @@ -293,3 +293,15 @@ $tick-script-overlay-margin: 30px; display: none; } } +/* + Data Explorer download CSV button + ----------------------------------------------------------------------------- +*/ +.icon.download.dlcsv:before { + content: "\e91d"; + font-weight: bold; + color: #bec2cc; +} +.btn.btn-sm.btn-default.dlcsv { + margin-left: 10px; +} From 43a2ec2519223c9d2566a1cdda6592f953e6cd41 Mon Sep 17 00:00:00 2001 From: Hunter Trujillo Date: Mon, 11 Sep 2017 15:53:04 -0600 Subject: [PATCH 2/9] Refactor download.js. Refactor resultsToCSV. Use Excel formatting. --- ui/src/external/download.js | 127 ++++++++++---------------- ui/src/shared/parsing/resultsToCSV.js | 21 +++-- 2 files changed, 64 insertions(+), 84 deletions(-) diff --git a/ui/src/external/download.js b/ui/src/external/download.js index ed16a881ed..358a3dd9ad 100644 --- a/ui/src/external/download.js +++ b/ui/src/external/download.js @@ -7,29 +7,34 @@ // v4.2 adds semantic variable names, long (over 2MB) dataURL support, and hidden by default temp anchors // https://github.com/rndme/download -const download = (data, strFileName, strMimeType) => { - let self = window, // this script is only for browsers anyway... - defaultMime = 'application/octet-stream', // this default mime also triggers iframe downloads - mimeType = strMimeType || defaultMime, - payload = data, - url = !strFileName && !strMimeType && payload, - anchor = document.createElement('a'), - toString = function(a) { - return String(a) - }, - myBlob = self.Blob || self.MozBlob || self.WebKitBlob || toString, - fileName = strFileName || 'download', - blob, - reader - myBlob = myBlob.call ? myBlob.bind(self) : Blob +const dataUrlToBlob = (myBlob, strUrl) => { + const parts = strUrl.split(/[:;,]/), + type = parts[1], + decoder = parts[2] === 'base64' ? atob : decodeURIComponent, + binData = decoder(parts.pop()), + mx = binData.length, + uiArr = new Uint8Array(mx) - if (String(this) === 'true') { - // reverse arguments, allowing download.bind(true, "text/xml", "export.xml") to act as a callback - payload = [payload, mimeType] - mimeType = payload[0] - payload = payload[1] + for (let i = 0; i < mx; ++i) { + uiArr[i] = binData.charCodeAt(i) } + return new myBlob([uiArr], {type}) +} + +const download = (data, strFileName, strMimeType) => { + const _window = window // this script is only for browsers anyway... + const defaultMime = 'application/octet-stream' // this default mime also triggers iframe downloads + let mimeType = strMimeType || defaultMime + let payload = data + let url = !strFileName && !strMimeType && payload + const anchor = document.createElement('a') + const toString = a => `${a}` + let myBlob = _window.Blob || _window.MozBlob || _window.WebKitBlob || toString + let fileName = strFileName || 'download' + let reader + myBlob = myBlob.call ? myBlob.bind(_window) : Blob + if (url && url.length < 2048) { // if no filename and no mime, assume a url was passed as the only argument fileName = url.split('/').pop().split('?')[0] @@ -49,43 +54,10 @@ const download = (data, strFileName, strMimeType) => { } // end if valid url? } // end if url? - // go ahead and download dataURLs right away - if (/^data\:[\w+\-]+\/[\w+\-]+[,;]/.test(payload)) { - if (payload.length > 1024 * 1024 * 1.999 && myBlob !== toString) { - payload = dataUrlToBlob(payload) - mimeType = payload.type || defaultMime - } else { - return navigator.msSaveBlob // IE10 can't do a[download], only Blobs: - ? navigator.msSaveBlob(dataUrlToBlob(payload), fileName) - : saver(payload) // everyone else can save dataURLs un-processed - } - } // end if dataURL passed? - - blob = - payload instanceof myBlob - ? payload - : new myBlob([payload], {type: mimeType}) - - function dataUrlToBlob(strUrl) { - let parts = strUrl.split(/[:;,]/), - type = parts[1], - decoder = parts[2] == 'base64' ? atob : decodeURIComponent, - binData = decoder(parts.pop()), - mx = binData.length, - i = 0, - uiArr = new Uint8Array(mx) - - for (i; i < mx; ++i) { - uiArr[i] = binData.charCodeAt(i) - } - - return new myBlob([uiArr], {type}) - } - - function saver(url, winMode) { + const saver = (saverUrl, winMode) => { if ('download' in anchor) { // html5 A[download] - anchor.href = url + anchor.href = saverUrl anchor.setAttribute('download', fileName) anchor.className = 'download-js-link' anchor.innerHTML = 'downloading...' @@ -96,31 +68,13 @@ const download = (data, strFileName, strMimeType) => { document.body.removeChild(anchor) if (winMode === true) { setTimeout(function() { - self.URL.revokeObjectURL(anchor.href) + _window.URL.revokeObjectURL(anchor.href) }, 250) } }, 66) return true } - // handle non-a[download] safari as best we can: - if ( - /(Version)\/(\d+)\.(\d+)(?:\.(\d+))?.*Safari\//.test(navigator.userAgent) - ) { - url = url.replace(/^data:([\w\/\-\+]+)/, defaultMime) - if (!window.open(url)) { - // popup blocked, offer direct download: - if ( - confirm( - 'Displaying New Document\n\nUse Save As... to download, then click back to return to this page.' - ) - ) { - location.href = url - } - } - return true - } - // do iframe dataURL download (old ch+FF): const f = document.createElement('iframe') document.body.appendChild(f) @@ -135,19 +89,36 @@ const download = (data, strFileName, strMimeType) => { }, 333) } // end saver + // go ahead and download dataURLs right away + if (/^data\:[\w+\-]+\/[\w+\-]+[,;]/.test(payload)) { + if (payload.length > 1024 * 1024 * 1.999 && myBlob !== toString) { + payload = dataUrlToBlob(myBlob, payload) + mimeType = payload.type || defaultMime + } else { + return navigator.msSaveBlob // IE10 can't do a[download], only Blobs: + ? navigator.msSaveBlob(dataUrlToBlob(myBlob, payload), fileName) + : saver(payload) // everyone else can save dataURLs un-processed + } + } // end if dataURL passed? + + const blob = + payload instanceof myBlob + ? payload + : new myBlob([payload], {type: mimeType}) + if (navigator.msSaveBlob) { // IE10+ : (has Blob, but not a[download] or URL) return navigator.msSaveBlob(blob, fileName) } - if (self.URL) { + if (_window.URL) { // simple fast and modern way using Blob and URL: - saver(self.URL.createObjectURL(blob), true) + saver(_window.URL.createObjectURL(blob), true) } else { // handle non-Blob()+non-URL browsers: if (typeof blob === 'string' || blob.constructor === toString) { try { - return saver(`data:${mimeType};base64,${self.btoa(blob)}`) + return saver(`data:${mimeType};base64,${_window.btoa(blob)}`) } catch (y) { return saver(`data:${mimeType},${encodeURIComponent(blob)}`) } @@ -155,7 +126,7 @@ const download = (data, strFileName, strMimeType) => { // Blob but not URL support: reader = new FileReader() - reader.onload = function(e) { + reader.onload = function() { saver(this.result) } reader.readAsDataURL(blob) diff --git a/ui/src/shared/parsing/resultsToCSV.js b/ui/src/shared/parsing/resultsToCSV.js index 3e89bbee2c..77edac44f1 100644 --- a/ui/src/shared/parsing/resultsToCSV.js +++ b/ui/src/shared/parsing/resultsToCSV.js @@ -1,13 +1,22 @@ import _ from 'lodash' +import moment from 'moment' const resultsToCSV = results => { - const name = _.get(results, ['0', 'series', '0', 'name']) - const columns = _.get(results, ['0', 'series', '0', 'columns']) - const values = _.get(results, ['0', 'series', '0', 'values']) + const {name, columns, values} = _.get(results, ['0', 'series', '0'], {}) + const [, ...cols] = columns + + const CSVString = [['date', ...cols].join(',')] + .concat( + values.map(([timestamp, ...measurements]) => + // MS Excel format + [moment(timestamp).format('M/D/YYYY h:mm:ss A'), ...measurements].join( + ',' + ) + ) + ) + .join('\n') - const CSVString = `\"${_.join(columns, '","')}\"\n${values - .map(arr => `${arr[0].toString()},${arr[1].toString()}`) - .join('\n')}` return {name, CSVString} } + export default resultsToCSV From da9da9b4f29d4a026298c4368ff85d36784f5539 Mon Sep 17 00:00:00 2001 From: deniz kusefoglu Date: Tue, 12 Sep 2017 09:41:43 -0700 Subject: [PATCH 3/9] Update CHANGELOG.md to reflect PR --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4254d1a947..8d2e1e50fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ### Features 1. [#1885](https://github.com/influxdata/chronograf/pull/1885): Add `fill` options to data explorer and dashboard queries 1. [#1978](https://github.com/influxdata/chronograf/pull/1978): Support editing kapacitor TICKScript +1. [#1992](https://github.com/influxdata/chronograf/pull/1992): Add .csv download button to data explorer ### UI Improvements From 3fab51d62bc786d9f49d695e3d4e3825a11d10da Mon Sep 17 00:00:00 2001 From: deniz kusefoglu Date: Tue, 12 Sep 2017 12:36:55 -0700 Subject: [PATCH 4/9] Add error handling to getCSV function --- ui/src/data_explorer/components/VisHeader.js | 11 ++++++++--- ui/src/data_explorer/components/Visualization.js | 3 +++ ui/src/data_explorer/containers/DataExplorer.js | 1 + 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/ui/src/data_explorer/components/VisHeader.js b/ui/src/data_explorer/components/VisHeader.js index cb7005b6dd..f54dfc6276 100644 --- a/ui/src/data_explorer/components/VisHeader.js +++ b/ui/src/data_explorer/components/VisHeader.js @@ -6,17 +6,18 @@ import {fetchTimeSeriesAsync} from 'shared/actions/timeSeries' import resultsToCSV from 'src/shared/parsing/resultsToCSV.js' import download from 'src/external/download.js' -const getCSV = query => async () => { +const getCSV = (query, errorThrown) => async () => { try { const {results} = await fetchTimeSeriesAsync({source: query.host, query}) const {name, CSVString} = resultsToCSV(results) download(CSVString, `${name}.csv`, 'text/plain') } catch (error) { + errorThrown(error) console.error(error) } } -const VisHeader = ({views, view, onToggleView, name, query}) => +const VisHeader = ({views, view, onToggleView, name, query, errorThrown}) =>
{views.length ?
@@ -32,7 +33,10 @@ const VisHeader = ({views, view, onToggleView, name, query}) => )} -
+
.csv
@@ -51,6 +55,7 @@ VisHeader.propTypes = { onToggleView: func.isRequired, name: string.isRequired, query: shape().isRequired, + errorThrown: func.isRequired, } export default VisHeader diff --git a/ui/src/data_explorer/components/Visualization.js b/ui/src/data_explorer/components/Visualization.js index 725cde9f22..5d106e301e 100644 --- a/ui/src/data_explorer/components/Visualization.js +++ b/ui/src/data_explorer/components/Visualization.js @@ -59,6 +59,7 @@ class Visualization extends Component { activeQueryIndex, isInDataExplorer, resizerBottomHeight, + errorThrown, } = this.props const {source: {links: {proxy}}} = this.context const {view} = this.state @@ -85,6 +86,7 @@ class Visualization extends Component { onToggleView={this.handleToggleView} name={cellName} query={query} + errorThrown={errorThrown} />
Date: Tue, 12 Sep 2017 13:11:16 -0700 Subject: [PATCH 5/9] Provide a more descriptive error text --- ui/src/data_explorer/components/VisHeader.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/data_explorer/components/VisHeader.js b/ui/src/data_explorer/components/VisHeader.js index f54dfc6276..855cf6b1eb 100644 --- a/ui/src/data_explorer/components/VisHeader.js +++ b/ui/src/data_explorer/components/VisHeader.js @@ -12,7 +12,7 @@ const getCSV = (query, errorThrown) => async () => { const {name, CSVString} = resultsToCSV(results) download(CSVString, `${name}.csv`, 'text/plain') } catch (error) { - errorThrown(error) + errorThrown(error, 'Unable to download .csv file') console.error(error) } } From c94a69366bdfa84ca6157c633d5b69e60bddc55e Mon Sep 17 00:00:00 2001 From: deniz kusefoglu Date: Tue, 12 Sep 2017 14:59:03 -0700 Subject: [PATCH 6/9] Add tests to results to CSV function --- ui/spec/shared/parsing/resultsToCSVSpec.js | 25 ++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 ui/spec/shared/parsing/resultsToCSVSpec.js diff --git a/ui/spec/shared/parsing/resultsToCSVSpec.js b/ui/spec/shared/parsing/resultsToCSVSpec.js new file mode 100644 index 0000000000..508696af81 --- /dev/null +++ b/ui/spec/shared/parsing/resultsToCSVSpec.js @@ -0,0 +1,25 @@ +import resultsToCSV from 'shared/parsing/resultsToCSV' + +describe('resultsToCSV', () => { + it('parses results to an object with name and CSVString keys', () => { + const results = [ + { + series: [ + { + name: 'some_name', + columns: ['col1', 'col2', 'col3', 'col4'], + values: [[1, 2, 3, 4], [(5, 6, 7, 8)]], + }, + ], + }, + ] + const response = resultsToCSV(results) + expect(response).to.have.all.keys('name', 'CSVString') + expect(response.name).to.be.a('string') + expect('foobar').to.not.include('/') + expect(response.CSVString).to.be.a('string') + }) +}) + +// make sure name does not contain things that would not be allowed in a filename. +// handle edge cases for columns and values. ? From e7b25a2bedec003a8652fa5bc6762eeaf51c0742 Mon Sep 17 00:00:00 2001 From: deniz kusefoglu Date: Tue, 12 Sep 2017 15:49:23 -0700 Subject: [PATCH 7/9] Improve testing on resultsToCSV --- ui/spec/shared/parsing/resultsToCSVSpec.js | 19 ++++++++++++++----- ui/src/shared/parsing/resultsToCSV.js | 7 ++++--- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/ui/spec/shared/parsing/resultsToCSVSpec.js b/ui/spec/shared/parsing/resultsToCSVSpec.js index 508696af81..1963c7cf4a 100644 --- a/ui/spec/shared/parsing/resultsToCSVSpec.js +++ b/ui/spec/shared/parsing/resultsToCSVSpec.js @@ -1,4 +1,4 @@ -import resultsToCSV from 'shared/parsing/resultsToCSV' +import resultsToCSV, {formatDate} from 'shared/parsing/resultsToCSV' describe('resultsToCSV', () => { it('parses results to an object with name and CSVString keys', () => { @@ -8,18 +8,27 @@ describe('resultsToCSV', () => { { name: 'some_name', columns: ['col1', 'col2', 'col3', 'col4'], - values: [[1, 2, 3, 4], [(5, 6, 7, 8)]], + values: [[1000000000, '2', 3, 4], [2000000000, '6', 7, 8]], }, ], }, ] const response = resultsToCSV(results) + const expected = `date,col2,col3,col4\n${formatDate( + 1000000000 + )},2,3,4\n${formatDate(2000000000)},6,7,8` expect(response).to.have.all.keys('name', 'CSVString') expect(response.name).to.be.a('string') - expect('foobar').to.not.include('/') expect(response.CSVString).to.be.a('string') + expect(response.CSVString).to.equal(expected) }) }) -// make sure name does not contain things that would not be allowed in a filename. -// handle edge cases for columns and values. ? +describe('formatDate', () => { + it('converts timestamp to an excel compatible date string', () => { + const timestamp = 1000000000000 + const result = formatDate(timestamp) + expect(result).to.be.a('string') + expect(+new Date(result)).to.equal(timestamp) + }) +}) diff --git a/ui/src/shared/parsing/resultsToCSV.js b/ui/src/shared/parsing/resultsToCSV.js index 77edac44f1..47596b16ec 100644 --- a/ui/src/shared/parsing/resultsToCSV.js +++ b/ui/src/shared/parsing/resultsToCSV.js @@ -1,6 +1,9 @@ import _ from 'lodash' import moment from 'moment' +export const formatDate = timestamp => + moment(timestamp).format('M/D/YYYY h:mm:ss A') + const resultsToCSV = results => { const {name, columns, values} = _.get(results, ['0', 'series', '0'], {}) const [, ...cols] = columns @@ -9,9 +12,7 @@ const resultsToCSV = results => { .concat( values.map(([timestamp, ...measurements]) => // MS Excel format - [moment(timestamp).format('M/D/YYYY h:mm:ss A'), ...measurements].join( - ',' - ) + [formatDate(timestamp), ...measurements].join(',') ) ) .join('\n') From a6adf97c1d765fbe91140a3012c92896611a9640 Mon Sep 17 00:00:00 2001 From: deniz kusefoglu Date: Tue, 12 Sep 2017 18:45:49 -0700 Subject: [PATCH 8/9] Improve testing on resultstoCSV --- ui/spec/shared/parsing/resultsToCSVSpec.js | 59 +++++++++++++--------- 1 file changed, 35 insertions(+), 24 deletions(-) diff --git a/ui/spec/shared/parsing/resultsToCSVSpec.js b/ui/spec/shared/parsing/resultsToCSVSpec.js index 1963c7cf4a..e2e2bae2b1 100644 --- a/ui/spec/shared/parsing/resultsToCSVSpec.js +++ b/ui/spec/shared/parsing/resultsToCSVSpec.js @@ -1,29 +1,5 @@ import resultsToCSV, {formatDate} from 'shared/parsing/resultsToCSV' -describe('resultsToCSV', () => { - it('parses results to an object with name and CSVString keys', () => { - const results = [ - { - series: [ - { - name: 'some_name', - columns: ['col1', 'col2', 'col3', 'col4'], - values: [[1000000000, '2', 3, 4], [2000000000, '6', 7, 8]], - }, - ], - }, - ] - const response = resultsToCSV(results) - const expected = `date,col2,col3,col4\n${formatDate( - 1000000000 - )},2,3,4\n${formatDate(2000000000)},6,7,8` - expect(response).to.have.all.keys('name', 'CSVString') - expect(response.name).to.be.a('string') - expect(response.CSVString).to.be.a('string') - expect(response.CSVString).to.equal(expected) - }) -}) - describe('formatDate', () => { it('converts timestamp to an excel compatible date string', () => { const timestamp = 1000000000000 @@ -32,3 +8,38 @@ describe('formatDate', () => { expect(+new Date(result)).to.equal(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 = { + name: 'procstat', + CSVString: `date,mean_cpu_usage\n${formatDate( + 1505262600000 + )},0.06163066773148772\n${formatDate( + 1505264400000 + )},2.616484718180463\n${formatDate(1505266200000)},1.6174323943535571`, + } + expect(response).to.have.all.keys('name', 'CSVString') + expect(response.name).to.be.a('string') + expect(response.CSVString).to.be.a('string') + expect(response.name).to.equal(expected.name) + expect(response.CSVString).to.equal(expected.CSVString) + }) +}) From 42076ba870ed17cadebbb2943daa423428f6d612 Mon Sep 17 00:00:00 2001 From: deniz kusefoglu Date: Wed, 13 Sep 2017 09:32:43 -0700 Subject: [PATCH 9/9] Add download.js license --- LICENSE_OF_DEPENDENCIES.md | 1 + 1 file changed, 1 insertion(+) diff --git a/LICENSE_OF_DEPENDENCIES.md b/LICENSE_OF_DEPENDENCIES.md index 3217a25668..3476dcda8f 100644 --- a/LICENSE_OF_DEPENDENCIES.md +++ b/LICENSE_OF_DEPENDENCIES.md @@ -391,6 +391,7 @@ * domutils 1.1.6 [Unknown](http://github.com/FB55/domutils) * domutils 1.5.1 [Unknown](http://github.com/FB55/domutils) * dot-prop 3.0.0 [MIT](https://github.com/sindresorhus/dot-prop) +* download 1.4.7 [MIT](https://github.com/rndme/download) * dygraphs 1.1.1 [Apache;BSD;MIT;MPL](http://github.com/danvk/dygraphs) * ecc-jsbn 0.1.1 [MIT](https://github.com/quartzjer/ecc-jsbn) * ee-first 1.1.1 [MIT](https://github.com/jonathanong/ee-first)