From 8432d0494fa69128fef05ef713fa0e5b50dd0897 Mon Sep 17 00:00:00 2001 From: Mike Stone Date: Tue, 24 Jan 2017 16:18:02 -0500 Subject: [PATCH] Allow users to export query results (#1082) --- .../QueryResultsTable/QueryResultsRow.jsx | 6 +- .../QueryResultsTable/QueryResultsTable.jsx | 13 ++- .../QueryResultsTable.tests.jsx | 17 +++- .../queries/QueryResultsTable/_styles.scss | 5 + frontend/kolide/index.js | 47 ++++++---- frontend/kolide/index.tests.js | 22 +++-- .../pages/queries/QueryPage/QueryPage.jsx | 91 ++++++++++++++----- ...QueryPage.tests.js => QueryPage.tests.jsx} | 39 ++++++-- .../redux/nodes/entities/campaigns/config.js | 6 +- .../redux/nodes/entities/campaigns/helpers.js | 19 ++-- .../nodes/entities/campaigns/helpers.tests.js | 20 ++-- .../convert_to_csv/convert_to_csv.tests.js | 20 ++++ frontend/utilities/convert_to_csv/index.js | 18 ++++ 13 files changed, 236 insertions(+), 87 deletions(-) rename frontend/pages/queries/QueryPage/{QueryPage.tests.js => QueryPage.tests.jsx} (68%) create mode 100644 frontend/utilities/convert_to_csv/convert_to_csv.tests.js create mode 100644 frontend/utilities/convert_to_csv/index.js diff --git a/frontend/components/queries/QueryResultsTable/QueryResultsRow.jsx b/frontend/components/queries/QueryResultsTable/QueryResultsRow.jsx index 9bc28b5fe3..a55125cebc 100644 --- a/frontend/components/queries/QueryResultsTable/QueryResultsRow.jsx +++ b/frontend/components/queries/QueryResultsTable/QueryResultsRow.jsx @@ -15,13 +15,13 @@ class QueryResultsRow extends Component { render () { const { index, queryResult } = this.props; - const { hostname } = queryResult; - const queryAttrs = omit(queryResult, ['hostname']); + const { host_hostname: hostHostname } = queryResult; + const queryAttrs = omit(queryResult, ['host_hostname']); const queryAttrValues = values(queryAttrs); return ( - {hostname} + {hostHostname} {queryAttrValues.map((attribute, i) => { return {attribute}; })} diff --git a/frontend/components/queries/QueryResultsTable/QueryResultsTable.jsx b/frontend/components/queries/QueryResultsTable/QueryResultsTable.jsx index 0aba59b4be..4a0bfac98c 100644 --- a/frontend/components/queries/QueryResultsTable/QueryResultsTable.jsx +++ b/frontend/components/queries/QueryResultsTable/QueryResultsTable.jsx @@ -1,7 +1,8 @@ -import React, { Component } from 'react'; +import React, { Component, PropTypes } from 'react'; import classnames from 'classnames'; import { get, keys, omit } from 'lodash'; +import Button from 'components/buttons/Button'; import campaignInterface from 'interfaces/campaign'; import filterArrayByHash from 'utilities/filter_array_by_hash'; import Icon from 'components/icons/Icon'; @@ -14,6 +15,7 @@ const baseClass = 'query-results-table'; class QueryResultsTable extends Component { static propTypes = { campaign: campaignInterface.isRequired, + onExportQueryResults: PropTypes.func, }; constructor (props) { @@ -117,7 +119,7 @@ class QueryResultsTable extends Component { } render () { - const { campaign } = this.props; + const { campaign, onExportQueryResults } = this.props; const { renderProgressDetails, renderTableHeaderRow, @@ -131,6 +133,13 @@ class QueryResultsTable extends Component { return (
+ {renderProgressDetails()}
diff --git a/frontend/components/queries/QueryResultsTable/QueryResultsTable.tests.jsx b/frontend/components/queries/QueryResultsTable/QueryResultsTable.tests.jsx index 31c123689f..63871d3159 100644 --- a/frontend/components/queries/QueryResultsTable/QueryResultsTable.tests.jsx +++ b/frontend/components/queries/QueryResultsTable/QueryResultsTable.tests.jsx @@ -1,5 +1,5 @@ import React from 'react'; -import expect from 'expect'; +import expect, { createSpy, restoreSpies } from 'expect'; import { keys } from 'lodash'; import { mount } from 'enzyme'; @@ -48,6 +48,8 @@ const campaignWithQueryResults = { }; describe('QueryResultsTable - component', () => { + afterEach(restoreSpies); + const componentWithoutQueryResults = mount( ); @@ -78,4 +80,17 @@ describe('QueryResultsTable - component', () => { expect(tableHeaderText).toInclude(key); }); }); + + it('calls the onExportQueryResults prop when the export button is clicked', () => { + const spy = createSpy(); + const component = mount(); + + const exportBtn = component.find('Button'); + + expect(spy).toNotHaveBeenCalled(); + + exportBtn.simulate('click'); + + expect(spy).toHaveBeenCalled(); + }); }); diff --git a/frontend/components/queries/QueryResultsTable/_styles.scss b/frontend/components/queries/QueryResultsTable/_styles.scss index 85a8f1f4ce..08240cd11b 100644 --- a/frontend/components/queries/QueryResultsTable/_styles.scss +++ b/frontend/components/queries/QueryResultsTable/_styles.scss @@ -4,6 +4,10 @@ max-width: 100%; box-sizing: border-box; + &__export-btn { + float: right; + } + &__filter-icon { &--is-active { color: $brand; @@ -20,6 +24,7 @@ border-radius: 3px; box-shadow: inset 0 0 8px 0 rgba(0, 0, 0, 0.12); overflow: scroll; + margin-top: 20px; max-height: 550px; } diff --git a/frontend/kolide/index.js b/frontend/kolide/index.js index aebdbb0750..6a02575038 100644 --- a/frontend/kolide/index.js +++ b/frontend/kolide/index.js @@ -63,7 +63,8 @@ class Kolide extends Base { getCounts: () => { const { STATUS_LABEL_COUNTS } = endpoints; - return this.authenticatedGet(this.endpoint(STATUS_LABEL_COUNTS)); + return this.authenticatedGet(this.endpoint(STATUS_LABEL_COUNTS)) + .catch(() => false); }, } @@ -76,6 +77,15 @@ class Kolide extends Base { }, } + queries = { + run: ({ query, selected }) => { + const { RUN_QUERY } = endpoints; + + return this.authenticatedPost(this.endpoint(RUN_QUERY), JSON.stringify({ query, selected })) + .then(response => response.campaign); + }, + } + users = { changePassword: (passwordParams) => { const { CHANGE_PASSWORD } = endpoints; @@ -104,6 +114,22 @@ class Kolide extends Base { }, } + websockets = { + queries: { + run: (campaignID) => { + return new Promise((resolve) => { + const socket = new global.WebSocket(`${this.websocketBaseURL}/v1/kolide/results/${campaignID}`); + + socket.onopen = () => { + socket.send(JSON.stringify({ type: 'auth', data: { token: local.getItem('auth_token') } })); + }; + + return resolve(socket); + }); + }, + }, + } + createLabel = ({ description, name, query }) => { const { LABELS } = endpoints; @@ -405,25 +431,6 @@ class Kolide extends Base { return this.authenticatedDelete(endpoint); } - runQuery = ({ query, selected }) => { - const { RUN_QUERY } = endpoints; - - return this.authenticatedPost(this.endpoint(RUN_QUERY), JSON.stringify({ query, selected })) - .then(response => response.campaign); - } - - runQueryWebsocket = (campaignID) => { - return new Promise((resolve) => { - const socket = new global.WebSocket(`${this.websocketBaseURL}/v1/kolide/results/${campaignID}`); - - socket.onopen = () => { - socket.send(JSON.stringify({ type: 'auth', data: { token: local.getItem('auth_token') } })); - }; - - return resolve(socket); - }); - } - setup = (formData) => { const { SETUP } = endpoints; const setupData = helpers.setupData(formData); diff --git a/frontend/kolide/index.tests.js b/frontend/kolide/index.tests.js index a11d222cfe..14176ae3da 100644 --- a/frontend/kolide/index.tests.js +++ b/frontend/kolide/index.tests.js @@ -296,17 +296,19 @@ describe('Kolide - API client', () => { .catch(done); }); - it('#runQuery', (done) => { - const data = { query: 'select * from users', selected: { hosts: [], labels: [] } }; - const request = validRunQueryRequest(bearerToken, data); + describe('#run', () => { + it('calls the correct endpoint with the correct params', (done) => { + const data = { query: 'select * from users', selected: { hosts: [], labels: [] } }; + const request = validRunQueryRequest(bearerToken, data); - Kolide.setBearerToken(bearerToken); - Kolide.runQuery(data) - .then(() => { - expect(request.isDone()).toEqual(true); - done(); - }) - .catch(done); + Kolide.setBearerToken(bearerToken); + Kolide.queries.run(data) + .then(() => { + expect(request.isDone()).toEqual(true); + done(); + }) + .catch(done); + }); }); }); diff --git a/frontend/pages/queries/QueryPage/QueryPage.jsx b/frontend/pages/queries/QueryPage/QueryPage.jsx index d0406dd3cf..d9a479c7f6 100644 --- a/frontend/pages/queries/QueryPage/QueryPage.jsx +++ b/frontend/pages/queries/QueryPage/QueryPage.jsx @@ -1,12 +1,14 @@ import React, { Component, PropTypes } from 'react'; -import { connect } from 'react-redux'; -import { push } from 'react-router-redux'; -import { first, filter, includes, isArray, isEqual, values } from 'lodash'; import classnames from 'classnames'; +import { connect } from 'react-redux'; +import FileSaver from 'file-saver'; +import { filter, includes, isArray, isEqual, size } from 'lodash'; +import moment from 'moment'; +import { push } from 'react-router-redux'; import Kolide from 'kolide'; -import campaignActions from 'redux/nodes/entities/campaigns/actions'; -import campaignInterface from 'interfaces/campaign'; +import campaignHelpers from 'redux/nodes/entities/campaigns/helpers'; +import convertToCSV from 'utilities/convert_to_csv'; import debounce from 'utilities/debounce'; import deepDifference from 'utilities/deep_difference'; import entityGetter from 'redux/utilities/entityGetter'; @@ -26,9 +28,8 @@ import Spinner from 'components/loaders/Spinner'; const baseClass = 'query-page'; -class QueryPage extends Component { +export class QueryPage extends Component { static propTypes = { - campaign: campaignInterface, dispatch: PropTypes.func, errors: PropTypes.shape({ base: PropTypes.string, @@ -43,10 +44,13 @@ class QueryPage extends Component { super(props); this.state = { + campaign: {}, queryIsRunning: false, targetsCount: 0, targetsError: null, }; + + this.csvQueryName = 'Query Results'; } componentWillMount () { @@ -68,6 +72,38 @@ class QueryPage extends Component { return false; } + onChangeQueryFormField = (fieldName, value) => { + if (fieldName === 'name') { + this.csvQueryName = value; + } + + return false; + } + + onExportQueryResults = (evt) => { + evt.preventDefault(); + + const { campaign } = this.state; + const { query_results: queryResults } = campaign; + + if (queryResults) { + const csv = convertToCSV(queryResults, (fields) => { + const result = filter(fields, f => f !== 'host_hostname'); + + result.unshift('host_hostname'); + + return result; + }); + const formattedTime = moment(new Date()).format('MM-DD-YY hh-mm-ss'); + const filename = `${this.csvQueryName} (${formattedTime}).csv`; + const file = new global.window.File([csv], filename, { type: 'text/csv' }); + + FileSaver.saveAs(file); + } + + return false; + } + onFetchTargets = (query, targetResponse) => { const { dispatch } = this.props; const { @@ -104,18 +140,17 @@ class QueryPage extends Component { return false; } - const { create, update } = campaignActions; const { destroyCampaign, removeSocket } = this; const selected = formatSelectedTargetsForApi(selectedTargets); removeSocket(); destroyCampaign(); - dispatch(create({ query: queryText, selected })) + Kolide.queries.run({ query: queryText, selected }) .then((campaignResponse) => { - return Kolide.runQueryWebsocket(campaignResponse.id) + return Kolide.websockets.queries.run(campaignResponse.id) .then((socket) => { - this.campaign = campaignResponse; + this.setState({ campaign: campaignResponse }); this.socket = socket; this.setState({ queryIsRunning: true }); @@ -129,10 +164,21 @@ class QueryPage extends Component { return false; } - return dispatch(update(this.campaign, socketData)) + return campaignHelpers.update(this.state.campaign, socketData) .then((updatedCampaign) => { + const { status } = updatedCampaign; + + if (status === 'finished') { + this.setState({ queryIsRunning: false }); + removeSocket(); + + return false; + } + this.previousSocketData = socketData; - this.campaign = updatedCampaign; + this.setState({ campaign: updatedCampaign }); + + return false; }); }; }); @@ -203,12 +249,11 @@ class QueryPage extends Component { }; destroyCampaign = () => { - const { campaign, dispatch } = this.props; - const { destroy } = campaignActions; + const { campaign } = this.state; - if (campaign) { + if (this.campaign || campaign) { this.campaign = null; - dispatch(destroy(campaign)); + this.setState({ campaign: {} }); } return false; @@ -225,20 +270,21 @@ class QueryPage extends Component { } renderResultsTable = () => { - const { campaign } = this.props; + const { campaign } = this.state; + const { onExportQueryResults } = this; const resultsClasses = classnames(`${baseClass}__results`, 'body-wrap', { [`${baseClass}__results--loading`]: this.socket && !campaign.query_results, }); let resultBody = ''; - if (!campaign || (!this.socket && !campaign.query_results)) { + if (!size(campaign) || (!this.socket && !campaign.query_results)) { return false; } if ((!campaign.query_results || campaign.query_results.length < 1) && this.socket) { resultBody = ; } else { - resultBody = ; + resultBody = ; } return ( @@ -250,6 +296,7 @@ class QueryPage extends Component { render () { const { + onChangeQueryFormField, onFetchTargets, onOsqueryTableSelect, onRunQuery, @@ -275,6 +322,7 @@ class QueryPage extends Component { { const stateEntities = entityGetter(state); const { id: queryID } = ownProps.params; - const { entities: campaigns } = stateEntities.get('campaigns'); const reduxQuery = entityGetter(state).get('queries').findBy({ id: queryID }); const { queryText, selectedOsqueryTable } = state.components.QueryPages; - const campaign = first(values(campaigns)); const { errors } = state.entities.queries; const queryStub = { description: '', name: '', query: queryText }; const query = reduxQuery || queryStub; @@ -329,7 +375,6 @@ const mapStateToProps = (state, ownProps) => { } return { - campaign, errors, hostIDs, query, diff --git a/frontend/pages/queries/QueryPage/QueryPage.tests.js b/frontend/pages/queries/QueryPage/QueryPage.tests.jsx similarity index 68% rename from frontend/pages/queries/QueryPage/QueryPage.tests.js rename to frontend/pages/queries/QueryPage/QueryPage.tests.jsx index 7742fd4248..0aa508bb47 100644 --- a/frontend/pages/queries/QueryPage/QueryPage.tests.js +++ b/frontend/pages/queries/QueryPage/QueryPage.tests.jsx @@ -1,11 +1,15 @@ +import React from 'react'; import expect, { spyOn, restoreSpies } from 'expect'; +import FileSave from 'file-saver'; import { mount } from 'enzyme'; +import { noop } from 'lodash'; +import convertToCSV from 'utilities/convert_to_csv'; import { defaultSelectedOsqueryTable } from 'redux/nodes/components/QueryPages/actions'; import helpers from 'test/helpers'; import kolide from 'kolide'; import queryActions from 'redux/nodes/entities/queries/actions'; -import QueryPage from 'pages/queries/QueryPage'; +import ConnectedQueryPage, { QueryPage } from 'pages/queries/QueryPage/QueryPage'; import { validUpdateQueryRequest } from 'test/mocks'; import { hostStub } from 'test/stubs'; @@ -37,13 +41,13 @@ describe('QueryPage - component', () => { }); it('renders the QueryForm component', () => { - const page = mount(connectedComponent(QueryPage, { mockStore, props: locationProp })); + const page = mount(connectedComponent(ConnectedQueryPage, { mockStore, props: locationProp })); expect(page.find('QueryForm').length).toEqual(1); }); it('renders the QuerySidePanel component', () => { - const page = mount(connectedComponent(QueryPage, { mockStore, props: locationProp })); + const page = mount(connectedComponent(ConnectedQueryPage, { mockStore, props: locationProp })); expect(page.find('QuerySidePanel').length).toEqual(1); }); @@ -51,15 +55,15 @@ describe('QueryPage - component', () => { it('sets selectedTargets based on host_ids', () => { const singleHostProps = { params: {}, location: { query: { host_ids: String(hostStub.id) } } }; const multipleHostsProps = { params: {}, location: { query: { host_ids: [String(hostStub.id), '99'] } } }; - const singleHostPage = mount(connectedComponent(QueryPage, { mockStore, props: singleHostProps })); - const multipleHostsPage = mount(connectedComponent(QueryPage, { mockStore, props: multipleHostsProps })); + const singleHostPage = mount(connectedComponent(ConnectedQueryPage, { mockStore, props: singleHostProps })); + const multipleHostsPage = mount(connectedComponent(ConnectedQueryPage, { mockStore, props: multipleHostsProps })); expect(singleHostPage.find('QueryPage').prop('selectedTargets')).toEqual([hostStub]); expect(multipleHostsPage.find('QueryPage').prop('selectedTargets')).toEqual([hostStub, { ...hostStub, id: 99 }]); }); it('sets targetError in state when the query is run and there are no selected targets', () => { - const page = mount(connectedComponent(QueryPage, { mockStore, props: locationProp })); + const page = mount(connectedComponent(ConnectedQueryPage, { mockStore, props: locationProp })); const form = page.find('QueryForm'); const runQueryBtn = form.find('.query-form__run-query-btn'); @@ -91,7 +95,7 @@ describe('QueryPage - component', () => { }, }, }); - const page = mount(connectedComponent(QueryPage, { + const page = mount(connectedComponent(ConnectedQueryPage, { mockStore: mockStoreWithQuery, props: locationWithQueryProp, })); @@ -115,4 +119,25 @@ describe('QueryPage - component', () => { type: 'queries_UPDATE_REQUEST', }); }); + + describe('export as csv', () => { + it('exports the campaign query results in csv format', () => { + const queryResult = { org_name: 'Kolide', org_url: 'https://kolide.co' }; + const campaign = { id: 1, query_results: [queryResult] }; + const queryResultsCSV = convertToCSV([queryResult]); + const fileSaveSpy = spyOn(FileSave, 'saveAs'); + const Page = mount(); + const filename = 'query_results.csv'; + const fileStub = new global.window.File([queryResultsCSV], filename, { type: 'text/csv' }); + + Page.setState({ campaign }); + Page.node.socket = {}; + + const QueryResultsTable = Page.find('QueryResultsTable'); + + QueryResultsTable.find('Button').simulate('click'); + + expect(fileSaveSpy).toHaveBeenCalledWith(fileStub); + }); + }); }); diff --git a/frontend/redux/nodes/entities/campaigns/config.js b/frontend/redux/nodes/entities/campaigns/config.js index 9bc2a24366..a6d45a6ff8 100644 --- a/frontend/redux/nodes/entities/campaigns/config.js +++ b/frontend/redux/nodes/entities/campaigns/config.js @@ -1,4 +1,4 @@ -import { destroyFunc, updateFunc } from 'redux/nodes/entities/campaigns/helpers'; +import { destroyFunc, update } from 'redux/nodes/entities/campaigns/helpers'; import Kolide from 'kolide'; import reduxConfig from 'redux/nodes/entities/base/reduxConfig'; import schemas from 'redux/nodes/entities/base/schemas'; @@ -6,9 +6,9 @@ import schemas from 'redux/nodes/entities/base/schemas'; const { CAMPAIGNS: schema } = schemas; export default reduxConfig({ - createFunc: Kolide.runQuery, + createFunc: Kolide.queries.run, destroyFunc, - updateFunc, + updateFunc: update, entityName: 'campaigns', schema, }); diff --git a/frontend/redux/nodes/entities/campaigns/helpers.js b/frontend/redux/nodes/entities/campaigns/helpers.js index 4ec87a7f0e..af2477f3bd 100644 --- a/frontend/redux/nodes/entities/campaigns/helpers.js +++ b/frontend/redux/nodes/entities/campaigns/helpers.js @@ -2,8 +2,8 @@ export const destroyFunc = (campaign) => { return Promise.resolve(campaign); }; -export const updateFunc = (campaign, socketData) => { - return new Promise((resolve, reject) => { +export const update = (campaign, socketData) => { + return new Promise((resolve) => { const { type, data } = socketData; if (type === 'totals') { @@ -17,9 +17,6 @@ export const updateFunc = (campaign, socketData) => { const queryResults = campaign.query_results || []; const hosts = campaign.hosts || []; const { host, rows } = data; - const newQueryResults = rows.map((row) => { - return { ...row, hostname: host.hostname }; - }); return resolve({ ...campaign, @@ -29,13 +26,19 @@ export const updateFunc = (campaign, socketData) => { ], query_results: [ ...queryResults, - ...newQueryResults, + ...rows, ], }); } - return reject(); + if (type === 'status') { + const { status } = data; + + return resolve({ ...campaign, status }); + } + + return resolve(campaign); }); }; -export default { destroyFunc, updateFunc }; +export default { destroyFunc, update }; diff --git a/frontend/redux/nodes/entities/campaigns/helpers.tests.js b/frontend/redux/nodes/entities/campaigns/helpers.tests.js index 6d27e0702b..3854630cc0 100644 --- a/frontend/redux/nodes/entities/campaigns/helpers.tests.js +++ b/frontend/redux/nodes/entities/campaigns/helpers.tests.js @@ -23,7 +23,7 @@ const campaignWithResults = { online: 2, }, }; -const { destroyFunc, updateFunc } = helpers; +const { destroyFunc, update } = helpers; const resultSocketData = { type: 'result', data: { @@ -55,14 +55,14 @@ describe('campaign entity - helpers', () => { }); }); - describe('#updateFunc', () => { + describe('#update', () => { it('appends query results to the campaign when the campaign has query results', (done) => { - updateFunc(campaignWithResults, resultSocketData) + update(campaignWithResults, resultSocketData) .then((response) => { expect(response.query_results).toEqual([ ...campaignWithResults.query_results, - { hostname: host.hostname, feature: 'product_name', value: 'Intel Core' }, - { hostname: host.hostname, feature: 'family', value: '0600' }, + { feature: 'product_name', value: 'Intel Core' }, + { feature: 'family', value: '0600' }, ]); expect(response.hosts).toInclude(host); done(); @@ -71,11 +71,11 @@ describe('campaign entity - helpers', () => { }); it('adds query results to the campaign when the campaign does not have query results', (done) => { - updateFunc(campaign, resultSocketData) + update(campaign, resultSocketData) .then((response) => { expect(response.query_results).toEqual([ - { hostname: host.hostname, feature: 'product_name', value: 'Intel Core' }, - { hostname: host.hostname, feature: 'family', value: '0600' }, + { feature: 'product_name', value: 'Intel Core' }, + { feature: 'family', value: '0600' }, ]); expect(response.hosts).toInclude(host); done(); @@ -84,7 +84,7 @@ describe('campaign entity - helpers', () => { }); it('updates totals on the campaign when the campaign has totals', (done) => { - updateFunc(campaignWithResults, totalsSocketData) + update(campaignWithResults, totalsSocketData) .then((response) => { expect(response.totals).toEqual(totalsSocketData.data); done(); @@ -93,7 +93,7 @@ describe('campaign entity - helpers', () => { }); it('adds totals to the campaign when the campaign does not have totals', (done) => { - updateFunc(campaign, totalsSocketData) + update(campaign, totalsSocketData) .then((response) => { expect(response.totals).toEqual(totalsSocketData.data); done(); diff --git a/frontend/utilities/convert_to_csv/convert_to_csv.tests.js b/frontend/utilities/convert_to_csv/convert_to_csv.tests.js new file mode 100644 index 0000000000..4a193e25e0 --- /dev/null +++ b/frontend/utilities/convert_to_csv/convert_to_csv.tests.js @@ -0,0 +1,20 @@ +import expect from 'expect'; + +import convertToCSV from 'utilities/convert_to_csv'; + +const objArray = [ + { + first_name: 'Mike', + last_name: 'Stone', + }, + { + first_name: 'Paul', + last_name: 'Simon', + }, +]; + +describe('convertToCSV - utility', () => { + it('converts an array of objects to CSV format', () => { + expect(convertToCSV(objArray)).toEqual('"first_name","last_name"\n"Mike","Stone"\n"Paul","Simon"'); + }); +}); diff --git a/frontend/utilities/convert_to_csv/index.js b/frontend/utilities/convert_to_csv/index.js new file mode 100644 index 0000000000..77f0c09cba --- /dev/null +++ b/frontend/utilities/convert_to_csv/index.js @@ -0,0 +1,18 @@ +import { keys } from 'lodash'; + +const defaultFieldSortFunc = fields => fields; + +const convertToCSV = (objArray, fieldSortFunc = defaultFieldSortFunc) => { + const fields = fieldSortFunc(keys(objArray[0])); + const jsonFields = fields.map(field => JSON.stringify(field)); + const rows = objArray.map((row) => { + return fields.map(field => JSON.stringify(row[field])).join(','); + }); + + + rows.unshift(jsonFields.join(',')); + + return rows.join('\n'); +}; + +export default convertToCSV;