Skip to content

Commit

Permalink
Fix/export convert bands (#46)
Browse files Browse the repository at this point in the history
* Add redux related code for exporting csv or json

* Add conversion of bands in csv/json download

* Use redux for the export of csv/json

* Add tests for export.js util

* Fix ESLint error (define before use)
  • Loading branch information
ONS-Tom authored and ONS-Anthony committed Apr 12, 2018
1 parent 6696037 commit 204afec
Show file tree
Hide file tree
Showing 8 changed files with 228 additions and 38 deletions.
65 changes: 65 additions & 0 deletions src/actions/ExportActions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { FORMING_CSV, FORMING_JSON, SET_CSV_ERROR_MESSAGE, SET_JSON_ERROR_MESSAGE } from '../constants/ExportConstants';
import { convertBands, formCSV } from '../utils/export';
import config from '../config/export';

const { FILE_NAME } = config;

const setForming = (type, sending) => ({ type, sending });
const setErrorMessage = (type, message) => ({ type, message });

/**
* @const exportCSV - Export the search results as a CSV
*
* @param {Array} results - The array of business objects
*/
export const exportCSV = (results) => (dispatch) => {
dispatch(setForming(FORMING_CSV, true));
dispatch(setErrorMessage(SET_CSV_ERROR_MESSAGE, ''));

setTimeout(() => {
Promise.all(convertBands(results)).then(res => {
const header = 'UBRN,Business Name,PostCode,Industry Code,Legal Status,Trading Status,Turnover,Employment,Company Reference Number';
const csv = formCSV(header, res);
const uri = `data:text/csv;charset=utf-8,${escape(csv)}`;
const link = document.createElement('a');
link.href = uri;
link.download = `${FILE_NAME}.csv`;
dispatch(setForming(FORMING_CSV, false));
link.click();
}).catch(() => {
dispatch(setErrorMessage(SET_CSV_ERROR_MESSAGE, 'Error: Unable to download CSV file.'));
});
}, 0);
};


/**
* @const exportJSON - Export the search results as JSON
*
* @param {Array} results - The array of business objects
*/
export const exportJSON = (results) => (dispatch) => {
dispatch(setForming(FORMING_JSON, true));
dispatch(setErrorMessage(SET_JSON_ERROR_MESSAGE, ''));

setTimeout(() => {
Promise.all(convertBands(results)).then(res => {
// There is an issue with a.click() when the JSON string to append to the DOM
// is too long, so we use a workaround from below.
// https://stackoverflow.com/a/19328891
const a = document.createElement('a');
document.body.appendChild(a);
a.style = 'display: none';
const json = JSON.stringify(res, null, 2);
const blob = new Blob([json], { type: 'octet/stream' });
const url = window.URL.createObjectURL(blob);
a.href = url;
a.download = `${FILE_NAME}.json`;
dispatch(setForming(FORMING_JSON, false));
a.click();
window.URL.revokeObjectURL(url);
}).catch(() => {
dispatch(setErrorMessage(SET_JSON_ERROR_MESSAGE, 'Error: Unable to download JSON file.'));
});
}, 0);
};
4 changes: 4 additions & 0 deletions src/constants/ExportConstants.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export const FORMING_CSV = 'FORMING_CSV';
export const FORMING_JSON = 'FORMING_JSON';
export const SET_CSV_ERROR_MESSAGE = 'SET_CSV_ERROR_MESSAGE';
export const SET_JSON_ERROR_MESSAGE = 'SET_JSON_ERROR_MESSAGE';
45 changes: 45 additions & 0 deletions src/reducers/exportResults.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { FORMING_CSV, FORMING_JSON, SET_CSV_ERROR_MESSAGE, SET_JSON_ERROR_MESSAGE } from '../constants/ExportConstants';

const initialState = {
formingCsv: false,
formingJson: false,
csvErrorMessage: '',
jsonErrorMessage: '',
};

/**
* @const exportReducer - The reducer to the exporting of results to CSV/JSON
*
* @param {Object} state - This current reducer state
* @param {Object} action - An action which holds the type and any data
*
* @return {Object} - The new state (after the action has been applied)
*/
const exportReducer = (state = initialState, action) => {
switch (action.type) {
case FORMING_CSV:
return Object.assign({}, state, {
...state,
formingCsv: action.sending,
});
case FORMING_JSON:
return Object.assign({}, state, {
...state,
formingJson: action.sending,
});
case SET_CSV_ERROR_MESSAGE:
return Object.assign({}, state, {
...state,
csvErrorMessage: action.message,
});
case SET_JSON_ERROR_MESSAGE:
return Object.assign({}, state, {
...state,
jsonErrorMessage: action.message,
});
default:
return state;
}
};

export default exportReducer;
3 changes: 2 additions & 1 deletion src/reducers/index.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { combineReducers } from 'redux';
import login from './login';
import apiSearch from './apiSearch';
import exportResults from './exportResults';
import { USER_LOGOUT } from '../constants/LoginConstants';

const appReducer = combineReducers({ login, apiSearch });
const appReducer = combineReducers({ login, apiSearch, exportResults });

/**
* @const rootReducer - Our root redux reducer
Expand Down
57 changes: 27 additions & 30 deletions src/utils/export.js
Original file line number Diff line number Diff line change
@@ -1,16 +1,30 @@
import config from '../config/export';
import { convertLegalStatus, convertTradingStatus, convertTurnover, convertEmploymentBands, pipe } from './helperMethods';

const { FILE_NAME } = config;

/**
* @const exportCSV - Create the CSV string
* @const transformBusiness - Convert the bands of each business in an array. We use
* the pipe helper method to pipe the return value of one function into the
* next function, so we can apply a sequence of transformations immutably.
*
* @param {Object} business - An array of business objects
*
* @return {Prmoise} - The promise which resolves to a business object with
* transformations applied
*/
const transformBusiness = (business) => new Promise((resolve) => resolve(pipe(
convertLegalStatus, convertTradingStatus, convertTurnover, convertEmploymentBands,
)(business)));


/**
* @const formCSV - Create the CSV string
*
* @param {string} header - The header to use in the CSV
* @param {Array} results - The results to save in a CSV file
*
* @return {string} A string of all the results in CSV format
*/
const exportCSV = (header, results) => {
const formCSV = (header, results) => {
const cols = ['id', 'businessName', 'postCode', 'industryCode', 'legalStatus', 'tradingStatus', 'turnover', 'employmentBands', 'companyNo'];
const rows = results.map(
leu => cols.map(
Expand All @@ -20,34 +34,17 @@ const exportCSV = (header, results) => {
return `${header}\r\n`.concat(rows.join(''));
};

/**
* @const downloadCSV - Download the results as a CSV file
*
* @param {Array} results - The results to save in a CSV file
*/
const downloadCSV = (results) => {
const header = 'UBRN,Business Name,PostCode,Industry Code,Legal Status,Trading Status,Turnover,Employment,Company Reference Number';
const csv = exportCSV(header, results);
const uri = `data:text/csv;charset=utf-8,${escape(csv)}`;
const link = document.createElement('a');
link.href = uri;
link.download = `${FILE_NAME}.csv`;
link.click();
};

/**
* @const downloadJSON - Download the results as a JSON file
* @const convertBands - Convert the bands of each business in an array
*
* @param {Array} results - The results to save in a JSON file
* @param {Array} results - An array of business objects
*
* @return {Array} - The array of Promises of business objects, we use promises as
* when converting the bands of potentially 10,000 results we don't want the
* UI to hang
*/
const downloadJSON = (results) => {
const jsonStr = JSON.stringify(results, null, 2);
const dataStr = `data:text/json;charset=utf-8,${encodeURIComponent(jsonStr)}`;
const download = document.createElement('a');
download.setAttribute('href', dataStr);
download.setAttribute('download', `${FILE_NAME}.json`);
download.click();
download.remove();
};
const convertBands = (results) => results.map(x => transformBusiness(x));


export { exportCSV, downloadCSV, downloadJSON };
export { formCSV, convertBands, transformBusiness };
37 changes: 36 additions & 1 deletion src/utils/helperMethods.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,38 @@
import React from 'react';
import { employmentBands, legalStatusBands, tradingStatusBands, turnoverBands } from './convertBands';

/**
* @const _pipe - Given two functions, f and g and curried arguments (...args), pass
* the result of f(...args) into g, returning the resulting function
*
* @param {function} f - The first function
* @param {function} g - The second function
*
* @return {function} - Return a function composition of calling g with the result
* of f(...args)
*/
const _pipe = (f, g) => (...args) => g(f(...args));

/**
* @const pipe - Given any number of functions, run those functions on a curried
* input.
*
* e.g. pipe(addOne, multiplyBy5)(2)
*
* The above will return 15 (assuming addOne and multiplyBy5 are implemented)
*
* @param {function} fns - Any number of functions
*
* @return {Any} - The result of running the provided functions on an argument
*/
const pipe = (...fns) => fns.reduce(_pipe);

// Below are immutable transformations on a business object to convert the bands
const convertLegalStatus = (x) => ({ ...x, legalStatus: legalStatusBands[x.legalStatus] });
const convertTradingStatus = (x) => ({ ...x, tradingStatus: tradingStatusBands[x.tradingStatus] });
const convertTurnover = (x) => ({ ...x, turnover: turnoverBands[x.turnover] });
const convertEmploymentBands = (x) => ({ ...x, employmentBands: employmentBands[x.employmentBands] });


/**
* @const maxSize - Given any number of arrays, return the size of the largest
Expand Down Expand Up @@ -151,5 +185,6 @@ const anyKeyEmpty = (obj) => Object.keys(obj).map(key => (obj[key] === '')).redu

export {
formatData, handleFormChange, formSelectJson, getHighlightedText,
everyKeyMatches, anyKeyEmpty, maxSize, numberWithCommas,
everyKeyMatches, anyKeyEmpty, maxSize, numberWithCommas, pipe,
convertLegalStatus, convertTradingStatus, convertTurnover, convertEmploymentBands,
};
21 changes: 17 additions & 4 deletions src/views/Results.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { exportCSV, exportJSON } from '../actions/ExportActions';
import Button from '../patterns/Button';
import Panel from '../patterns/Panel';
import ResultsTable from '../components/ResultsTable';
import ResultsList from '../components/ResultsList';
import ResultsSearchForm from '../components/ResultsSearchForm';
import { numberWithCommas } from '../utils/helperMethods';
import { downloadCSV, downloadJSON } from '../utils/export';

/**
* @class Results - Results will be used with the SearchHOC which provides the correct
Expand All @@ -16,6 +17,8 @@ import { downloadCSV, downloadJSON } from '../utils/export';
*
* @todo - The Panel for displaying how many results have been capped should be created
* as a component
*
* @todo - Add in error panels for csv/json download
*/
class Results extends React.Component {
constructor(props) {
Expand Down Expand Up @@ -106,9 +109,9 @@ class Results extends React.Component {
<div>
<div className="key-line-download"></div>
<h3 className="saturn">Download your search results</h3>
<Button className="btn btn--primary venus btn--wide" id="downloadCsvButton" type="submit" text="CSV" onClick={() => downloadCSV(this.props.results)} ariaLabel="Download CSV Button" loading={false} />
<Button className="btn btn--primary venus btn--wide" id="downloadCsvButton" type="submit" text="CSV" onClick={() => this.props.dispatch(exportCSV(this.props.results))} ariaLabel="Download CSV Button" loading={this.props.formingCsv} />
&nbsp;
<Button className="btn btn--primary venus btn--wide" id="downloadJsonButton" type="submit" text="JSON" onClick={() => downloadJSON(this.props.results)} ariaLabel="Download JSON Button" loading={false} />
<Button className="btn btn--primary venus btn--wide" id="downloadJsonButton" type="submit" text="JSON" onClick={() => this.props.dispatch(exportJSON(this.props.results))} ariaLabel="Download JSON Button" loading={this.props.formingJson} />
</div>
}
</div>
Expand All @@ -130,6 +133,16 @@ Results.propTypes = {
onSubmit: PropTypes.func.isRequired,
closeModal: PropTypes.func.isRequired,
toHighlight: PropTypes.string.isRequired,
dispatch: PropTypes.func.isRequired,
formingCsv: PropTypes.bool.isRequired,
formingJson: PropTypes.bool.isRequired,
};

export default Results;
const select = (state) => ({
formingCsv: state.exportResults.formingCsv,
formingJson: state.exportResults.formingJson,
csvErrorMessage: state.exportResults.csvErrorMessage,
jsonErrorMessage: state.exportResults.jsonErrorMessage,
});

export default connect(select)(Results);
34 changes: 32 additions & 2 deletions test/utils-spec/export-test.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { exportCSV } from '../../src/utils/export';
import { formCSV, convertBands } from '../../src/utils/export';
import { returnBusiness } from '../../src/utils/requestUtils';

describe("export.js test suite", () => {
it("creates a valid CSV string from an array of businesses", () => {
const header = 'UBRN,Business Name,PostCode,Industry Code,Legal Status,Trading Status,Turnover,Employment,Company Reference Number';
const results = Array.from({ length: 10 }, () => returnBusiness());
const csv = exportCSV(header, results);
const csv = formCSV(header, results);
const splitCsv = csv.split(/\r?\n/);
const csvHeader = splitCsv[0];

Expand All @@ -24,4 +24,34 @@ describe("export.js test suite", () => {
// Do a last check on the length to verify they are the same
expect(results.length).toBe(splitCsvNoHeader.length);
});

it("converts the bands correctly", () => {
const business = {
id: '020541',
businessName: 'TEST GRILL LTD',
postCode: 'ID80 5QB',
industryCode: '86762',
legalStatus: '2',
tradingStatus: 'A',
turnover: 'A',
employmentBands: 'B',
companyNo: '2953156',
}

const expected = [{
id: '020541',
businessName: 'TEST GRILL LTD',
postCode: 'ID80 5QB',
industryCode: '86762',
legalStatus: 'Sole Proprietor',
tradingStatus: 'Active',
turnover: '0-99',
employmentBands: '1',
companyNo: '2953156',
}];

Promise.all(convertBands([business])).then(result => {
expect(JSON.stringify(expected)).toBe(JSON.stringify(result));
});
});
});

0 comments on commit 204afec

Please sign in to comment.