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;