Last active
August 2, 2019 16:44
-
-
Save dfkaye/813b17c70a2a93f086832732bd090599 to your computer and use it in GitHub Desktop.
csv helpers and tests - thanks to Ben Nadel whose csv-to-array function (2009) lives
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
export { transform, parse, match }; | |
/* TRANSFORM DATA TO CSV */ | |
/** | |
* @function transform accepts an array of objects and returns a string of | |
* comma-separated values based on the object's key-value entries. Each row in | |
* the output ends with the newline character. If the array argument is nothing | |
* or empty, function returns an empty string. | |
* | |
* Function accepts an optional `columns` array of columns to be included. If | |
* the column is found it is included, if it is not found it is ignored. If no | |
* column in the columns are found, then all data is transformed. | |
* | |
* This function is the exported entry point for converting data to CSV. | |
* | |
* See example at https://en.wikipedia.org/wiki/Comma-separated_values#Example | |
* | |
* @param {array} array of data to transform | |
* @param {array} column IDs to include | |
* @returns {string} CSV | |
*/ | |
function transform({ array, columns = [] }) { | |
// Return an empty string if the array is nothing or empty. | |
if (!array || !array.length) { | |
return ''; | |
} | |
const headers = getHeaders({ from: array[0] }); | |
const subset = []; | |
columns.forEach(column => { | |
headers.indexOf(column) > -1 && (subset.push(column)); | |
}); | |
const keys = subset.length > 0 ? subset : headers; | |
const data = array.map(row => { | |
return keys | |
// Map each entry in the row by its header key into an array, | |
.map(header => { | |
const value = row[header]; | |
return (value && typeof value == 'object') | |
// Note: we enclose a flattened object's key-value pairs in | |
// doublequotes for readability. | |
? "\"" + transformObject({ value }) + "\"" | |
: transformString({ value }); | |
}) | |
// ...then join the array with commas and return. | |
.join(','); | |
}); | |
return [keys.join(',')].concat(data).join('\n'); | |
} | |
/** | |
* @function getHeaders is a helper function that extracts valid keys from an | |
* object as the headers for a CSV string. | |
* | |
* Not exported. | |
* | |
* @param {object} from | |
* @returns {array} validKeys | |
*/ | |
function getHeaders({ from }) { | |
return Object.keys(from).filter(key => { | |
return key != null // not null or undefined values | |
&& !/^null|undefined$/.test(key) // not null or undefined strings | |
&& /^[^\s]+$/.test(key) // not empty or whitespace-only | |
&& !/^NaN$/.test(key) // not NaN string | |
// eslint-disable-next-line | |
&& key === key ; // not NaN | |
}); | |
} | |
/** | |
* @function transformObject is a helper function to process a non-null object | |
* or array, and returns a flattened string version of the key-value entries, | |
* separated by semi-colons. | |
* | |
* Not exported. | |
* | |
* Process supports recursion in order that the code structure is less | |
* repetitive, but in real life, data to be transformed into CSV should be flat | |
* as possible. | |
* | |
* Some examples: | |
* | |
* Given: { field: [ { name: ['hello'] }, { name: 'two' } ] } | |
* Result "name: hello; name: two" | |
* | |
* Given: { field: { name: ['hello'] } } | |
* Result: "name: hello" | |
* | |
* @param {object} value | |
* @returns {string} | |
*/ | |
function transformObject({ value }) { | |
let result = ['']; | |
if (Array.isArray(value)) { | |
result = value.map(item => { | |
if (item && typeof item == 'object') { | |
return transformObject({ value: item }) | |
} | |
return item; | |
}); | |
} else { | |
result = Object.keys(value).map(k => { | |
const item = value[k]; | |
const update = (item && typeof item == 'object') | |
? transformObject({ value: item }) | |
: transformString({ value: item }); | |
return k + ': ' + update; | |
}); | |
} | |
return result.join('; '); | |
} | |
/** | |
* @function transformString is a helper function to process a string value, | |
* prefixing characters (comma, quote, doublequote) with a doublequote. If the | |
* resulting value contains a newline or doublequote, the entire output is | |
* enclosed with doublequotes. | |
* | |
* Not exported. | |
* | |
* Some examples: | |
* | |
* Given: 'This -> \n <- is a newline' | |
* Result: '"This -> \n <- is a newline"' | |
* | |
* Given: 'I said, "I am doublequoted, with a comma."' | |
* Result: '"I said", ""I am doublequoted", with a comma."""' | |
* | |
* @param {object} value | |
* @returns {string} result | |
*/ | |
function transformString({ value }) { | |
const rePrefixableChars = /["]+/g; | |
const reQuotableChars = /[",\n\r]+/g; | |
const updated = (value != null ? String(value) : '') | |
.replace(rePrefixableChars, function(c) { | |
return `"${ c }`; | |
}); | |
return updated.match(reQuotableChars) ? `"${ updated }"` : updated; | |
} | |
/* PARSE CSV TO DATA */ | |
/** | |
* @function parse accepts a CSV string and optional key (columnId) and creates | |
* an array of arrays. If the key is specified and is found in the headers row | |
* (first array), then the returned array will contain only the values found at | |
* each row at the key's position. Otherwise an empty array is returned. | |
* | |
* This function is the exported entry point for converting a CSV string into | |
* an array. | |
* | |
* This function depends on Ben Nadel's CSVToArray, detailed below. | |
* | |
* @param {string} csv | |
* @param {string} key | |
* @returns {array} | |
*/ | |
function parse({ csv, key }) { | |
// Protect against empty parameters; else CSVToArray will not terminate. | |
if (!(key && csv)) { | |
return []; | |
} | |
// Create the array of arrays (row data) from Ben Nadel's function. | |
const array = CSVToArray(csv); | |
// Find the column name (key) so we return only those values from each row. | |
const index = array[0].indexOf(key); | |
// Return array of values at key in row. | |
return index > -1 ? array.slice(1).map(row => row[index]) : []; | |
} | |
/** | |
* @function CSVToArray is helper that creates an array of arrays (row data) | |
* from CSV string argument. Function repeatedly calls RegExp.exec() on the | |
* incoming CSV and pushes each match to an array of row data. Matches can be | |
* on quoted versus unquoted entries, and on row delimiters. | |
* | |
* See comments in function body for more details. | |
* | |
* This is modified from the masterful version published by Ben Nadel in 2009. | |
* See https://www.bennadel.com/blog/1504-ask-ben-parsing-csv-strings-with-javascript-exec-regular-expression-command.htm | |
* | |
* @param {string} csv | |
* @returns {array} of arrays (row data) | |
*/ | |
function CSVToArray(csv) { | |
// Create a regular expression to parse the CSV values. | |
var objPattern = new RegExp( | |
( | |
// Delimiters. | |
"(\\,|\\r?\\n|\\r|^)" + | |
// Quoted fields. | |
"(?:\"([^\"]*(?:\"\"[^\"]*)*)\"|" + | |
// Standard fields. | |
"([^\"\\,\\r\\n]*))" | |
), | |
"gi" | |
); | |
// Create an array to hold our data. Give the array | |
// a default empty first row. | |
var currentRow; | |
var arrData = [currentRow = []]; | |
// Create an array to hold our individual pattern matching groups. | |
var arrMatches; | |
var strMatchedDelimiter; | |
var strMatchedValue; | |
// Keep looping over the regular expression matches until we can no longer | |
// find a match. | |
// eslint-disable-next-line | |
while (arrMatches = objPattern.exec( csv )) { | |
// Get the delimiter that was found. | |
strMatchedDelimiter = arrMatches[ 1 ]; | |
// Check to see if the given delimiter has a length (is not the start of | |
// string) and if it matches field delimiter. If it does not, then we know | |
// that this delimiter is a row delimiter. | |
if (strMatchedDelimiter.length && (strMatchedDelimiter !== ',')) { | |
// Since we have reached a new row of data, add an empty row to our data | |
// array. | |
currentRow = []; | |
arrData.push( currentRow ); | |
} | |
// Now that we have our delimiter out of the way, let's check to see which | |
// kind of value we captured (quoted or unquoted). | |
if (arrMatches[ 2 ]) { | |
// We found a quoted value. When we capture this value, unescape any | |
// double quotes. | |
strMatchedValue = arrMatches[ 2 ].replace(/""/g, '"'); | |
} else { | |
// We found a non-quoted value. | |
strMatchedValue = arrMatches[ 3 ]; | |
} | |
// Now that we have our value string, let's add it to the data array. | |
currentRow.push( strMatchedValue ); | |
} | |
// Return the parsed data. | |
return( arrData ); | |
} | |
/* MATCH IDS IN UPLOADED FIELDS WITH IDS IN DATA ARRAY */ | |
/** | |
* @function match compares array of IDs with array of table data. If an ID is | |
* not found in the table data, it is pushed to an `unmatched` array; otherwise | |
* it is pushed to a `matched` array. The `match` and `unmatched` arrays are | |
* returned in an object. | |
* | |
* This function is the exported entry point for matching IDs between 2 arrays | |
* after a user has uploaded a CSV file. | |
* | |
* @param {array} ids | |
* @param {array} data | |
* @returns {object} containing ids and data arrays | |
*/ | |
function match({ ids, data }) { | |
var test = ids.slice(); | |
var subset = data.map(row => row.id); | |
var items = { matched: [], unmatched: [] }; | |
test.reduce((items, id) => { | |
subset.indexOf(id) === -1 ? items.unmatched.push(id) : items.matched.push(id); | |
return items; | |
}, items); | |
return items; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 14 July 2019 | |
// Why the `while( objPattern.exec(csv) )` test is unsafe. | |
// 1. The RegExp object contains capture groups, no exact matches. | |
// 2. When csv is undefined, the value is coerced to a string, "undefined", and an array of | |
// match properties is returned - i.e., the result is not false-y. | |
// 3. Because of the global flag, the RegExp is stateful - i.e., when exec completes, the lastIndex | |
// is set to 9 (the length of the string value). | |
// 4. When exec runs again, the value is again coerced and the RegExp starts over with | |
// a new value, rather than *not advancing* over the old value. | |
// (i.e., the lastIndex property is reset upon each iteration, see | |
// (https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp/exec | |
// 5. Interestingly, `null` and `false` are not matched by `objPattern.exec()` because they are not | |
// explicitly tested as string sequences. | |
var objPattern = new RegExp( | |
( | |
// Delimiters. | |
"(\\,|\\r?\\n|\\r|^)" + | |
// Quoted fields. | |
"(?:\"([^\"]*(?:\"\"[^\"]*)*)\"|" + | |
// Standard fields. | |
"([^\"\\,\\r\\n]*))" | |
), | |
"gi" | |
); | |
var tests = [ | |
undefined, | |
null, | |
'', | |
' ', | |
',', | |
'/n', | |
0, | |
false, | |
'\'', | |
'\"' | |
]; | |
var results = tests.map(test => { | |
return objPattern.exec(test) | |
}); | |
var report = JSON.stringify(results, null, 4); | |
console.log(report); | |
// More pathological result. Capture group is defined, but nothing is captured. | |
// Hence even if null, the argument produces `Array [ "", "" ]` | |
var re = /()/g; | |
var csv = null; | |
for (var i = 0; i < 5; i++) { | |
console.log(re.exec(csv)) | |
} | |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { transform, parse, match } from '../components/shared/data/csv.js'; | |
describe('data/csv', () => { | |
/* transform array of data to CSV format */ | |
describe('transform array to csv', () => { | |
describe("transform({ array }) - all columns", () => { | |
const fixture = [ | |
{ | |
id: "first", | |
value: "one", | |
"": 'should ignore', | |
" \n ": 'should ignore', | |
null: 'should ignore', | |
undefined: 'should ignore', | |
NaN: 'should ignore', | |
[null]: 'should ignore', | |
[undefined]: 'should ignore', | |
[NaN]: 'should ignore' | |
}, | |
{ | |
id: "second", | |
value: "two", | |
}, | |
{ | |
id: "empty", | |
value: "" | |
}, | |
{ | |
id: "nullValue", | |
value: null | |
}, | |
{ | |
id: "undefinedValue", | |
value: null | |
}, | |
{ | |
id: "quoted", | |
value: "I said 'I am quoted.'" | |
}, | |
{ | |
id: "doublequoted", | |
value: "I said \"I am doublequoted.\"" | |
}, | |
{ | |
id: "comma", | |
value: "because, of course" | |
}, | |
{ | |
id: "newline", | |
value: "This -> \n <- is a newline" | |
}, | |
]; | |
it('returns valid headers, ignores invalid headers', () => { | |
var csv = transform({ array: fixture }); | |
var rows = csv.split('\n'); | |
expect(rows[0]).toBe("id,value"); | |
}); | |
it('returns data for valid headers', () => { | |
var csv = transform({ array: fixture }); | |
var rows = csv.split('\n'); | |
expect(rows[1]).toBe("first,one"); | |
expect(rows[2]).toBe("second,two"); | |
}); | |
it('replaces empty value with nothing', () => { | |
var csv = transform({ array: fixture }); | |
var rows = csv.split('\n'); | |
expect(rows[3]).toBe("empty,"); | |
expect(rows[4]).toBe("nullValue,"); | |
expect(rows[5]).toBe("undefinedValue,"); | |
}); | |
it('accepts quoted strings', () => { | |
var csv = transform({ array: fixture }); | |
var rows = csv.split('\n'); | |
expect(rows[6]).toBe(`quoted,I said 'I am quoted.'`); | |
}); | |
it('prefixes doublequote characters with doublequote', () => { | |
var csv = transform({ array: fixture }); | |
var rows = csv.split('\n'); | |
expect(rows[7]).toBe(`doublequoted,"I said ""I am doublequoted."""`); | |
}); | |
it('encloses an entry containing comma characters with doublequote', () => { | |
var csv = transform({ array: fixture }); | |
var rows = csv.split('\n'); | |
expect(rows[8]).toBe(`comma,"because, of course"`); | |
}); | |
it('encloses an entry containing newline character with doublequotes', () => { | |
var csv = transform({ array: fixture }); | |
var temp = csv.replace('-> \n <-', '*TEMP*') | |
var rows = temp.split('\n'); | |
var row = rows[9].replace('*TEMP*', '-> \n <-'); | |
expect(row).toBe(`newline,"This -> \n <- is a newline"`); | |
}); | |
it('returns a complete CSV that looks like this...', () => { | |
var expected = [ | |
`id,value`, | |
`first,one`, | |
`second,two`, | |
`empty,`, | |
`nullValue,`, | |
`undefinedValue,`, | |
`quoted,I said 'I am quoted.'`, | |
`doublequoted,"I said ""I am doublequoted."""`, | |
`comma,"because, of course"`, | |
`newline,"This -> \n <- is a newline"` | |
].join('\n'); | |
var actual = transform({ array: fixture }); | |
expect(actual).toBe(expected); | |
}); | |
it('flattens nested data', () => { | |
const fixture = [ | |
{ | |
cycleId: "ST:CCL:7e6d9adc-2715-418a-b23d-1d94ccf34f04", | |
history: [ | |
{ state: "INITIAL", updateDate: "2019-06-06T10:54:21.678Z" }, | |
{ state: "NEW", updateDate: "2019-06-06T10:54:21.682Z" }, | |
{ state: "AUTH_APPROVED", updateDate: "2019-06-06T10:54:23.769Z" }, | |
{ state: "PENDING_PROBATION", updateDate: "2019-06-06T10:54:24.139Z" }, | |
{ state: "PENDING_CYCLE", updateDate: "2019-06-06T11:04:21.697Z" }, | |
{ state: "IN_CYCLE", updateDate: "2019-06-06T11:04:24.277Z" } | |
], | |
id: "PA:TX:a0d3d376-8835-446a-8539-3742464d02fd", | |
meta: { | |
createdOn: "2019-06-06T10:54:21.678Z", | |
modifiedOn: "2019-06-06T11:04:24.277Z" | |
}, | |
orderId: "PA:OR:e214b667-eec4-4096-8271-a7cd99850337", | |
orderType: "PAYMENT", | |
sourceAmount: { | |
value: 54321, | |
currency: 'USD', | |
exponent: 2 | |
}, | |
state: 'IN_CYCLE', | |
transactionType: 'PAYIN', | |
[null]: null, | |
[undefined]: undefined, | |
} | |
]; | |
var csv = transform({ array: fixture }); | |
// We know this fixture doesn't have newlines or commas inside a value | |
// so split it with confidence. | |
var rows = csv.split('\n'); | |
var data = rows[1].split(','); | |
var item = fixture[0]; | |
expect(data[0]).toBe(item.cycleId); | |
expect(data[1]).toBe('"' + [ | |
'state: INITIAL; updateDate: 2019-06-06T10:54:21.678Z', | |
'state: NEW; updateDate: 2019-06-06T10:54:21.682Z', | |
'state: AUTH_APPROVED; updateDate: 2019-06-06T10:54:23.769Z', | |
'state: PENDING_PROBATION; updateDate: 2019-06-06T10:54:24.139Z', | |
'state: PENDING_CYCLE; updateDate: 2019-06-06T11:04:21.697Z', | |
'state: IN_CYCLE; updateDate: 2019-06-06T11:04:24.277Z' | |
].join('; ') + '"'); | |
expect(data[2]).toBe(item.id); | |
expect(data[3]).toBe('"' + [ | |
"createdOn: 2019-06-06T10:54:21.678Z", | |
"modifiedOn: 2019-06-06T11:04:24.277Z" | |
].join('; ') + '"'); | |
expect(data[4]).toBe(item.orderId); | |
expect(data[5]).toBe(item.orderType); | |
expect(data[6]).toBe('"' + [ | |
"value: 54321", | |
"currency: USD", | |
"exponent: 2" | |
].join('; ') + '"'); | |
expect(data[7]).toBe(item.state); | |
expect(data[8]).toBe(item.transactionType); | |
}); | |
it('returns empty string if data is empty', () => { | |
var csv = transform({ array: [] }); | |
expect(csv).toBe(''); | |
}); | |
}); | |
describe('transform({ array, columns }) - specified columns', () => { | |
const fixture = [ | |
{ id: '1234', name: 'david', value: '' }, | |
{ id: '5678', name: 'avdid', value: 2 }, | |
{ id: '9012', name: 'vaidd', value: 10.077 }, | |
{ id: '3456', name: 'addiv', value: "1,357.91" }, | |
]; | |
it('should return all columns when columns param not specified', () => { | |
var csv = transform({ array: fixture }); | |
var rows = csv.split('\n'); | |
var headers = rows[0].split(','); | |
expect(headers.length).toBe(3); | |
expect(headers[0]).toBe('id'); | |
expect(headers[1]).toBe('name'); | |
expect(headers[2]).toBe('value'); | |
}); | |
it('should return specified columns', () => { | |
var csv = transform({ array: fixture, columns: ['name'] }); | |
var rows = csv.split('\n') | |
var headers = rows[0].split(','); | |
expect(headers.length).toBe(1); | |
expect(headers[0]).toBe('name'); | |
}); | |
it('should return all columns if no specified columns are found', () => { | |
var csv = transform({ array: fixture, columns: ['bizarre', 'cognomen'] }); | |
var rows = csv.split('\n') | |
var headers = rows[0].split(','); | |
expect(headers.length).toBe(3); | |
expect(headers[0]).toBe('id'); | |
expect(headers[1]).toBe('name'); | |
expect(headers[2]).toBe('value'); | |
}); | |
it('should return specified columns found and ignore columns not found', () => { | |
var csv = transform({ array: fixture, columns: ['id', 'cognomen'] }); | |
var rows = csv.split('\n') | |
var headers = rows[0].split(','); | |
expect(headers.length).toBe(1); | |
expect(headers[0]).toBe('id'); | |
}); | |
}); | |
}); | |
/* parse CSV to compare with fields in data */ | |
describe('parse({ csv })', () => { | |
// placeholder - will probably split into several things internally. | |
const fixture = [ | |
`id,value`, | |
`first,one`, | |
`second,two`, | |
`empty,`, | |
`nullValue,`, | |
`undefinedValue,`, | |
`quoted,I said 'I am quoted.'`, | |
`doublequoted,"I said ""I am doublequoted."""`, | |
`comma,"because, of course"`, | |
`newline,"This -> \n <- is a newline"` | |
].join('\n'); | |
const expected = [ | |
['id', 'value' ], | |
['first','one'], | |
['second','two'], | |
['empty', ''], | |
['nullValue', ''], | |
['undefinedValue', ''], | |
['quoted', "I said 'I am quoted.'"], | |
['doublequoted', 'I said "I am doublequoted."'], | |
['comma', "because, of course"], | |
['newline', "This -> \n <- is a newline"] | |
]; | |
/* Success test */ | |
it('returns only values at specified key in each row', () => { | |
var key = 'id'; | |
var index = expected[0].indexOf(key); | |
var csv = fixture.toString(); | |
var actual = parse({ csv, key }); | |
expect(actual).toEqual(expected.slice(1).map(row => row[index])); | |
}); | |
/* Bad input tests */ | |
it('returns empty array if key is not specified', () => { | |
var csv = fixture.toString(); | |
var actual = parse({ csv }); | |
expect(actual.length).toBe(0); | |
}); | |
it('returns empty array if key is empty', () => { | |
var key = ''; | |
var csv = fixture.toString(); | |
var actual = parse({ csv, key }); | |
expect(actual.length).toBe(0); | |
}); | |
it('returns empty array if key is not found in headers', () => { | |
var key = 'bogosity'; | |
var csv = fixture.toString(); | |
var actual = parse({ csv, key }); | |
expect(actual.length).toBe(0); | |
}); | |
it('returns empty array if csv is empty', () => { | |
var key = 'id'; | |
var csv = ''; | |
var actual = parse({ csv, key }); | |
expect(actual.length).toBe(0); | |
}); | |
it('returns empty array if csv is falsey', () => { | |
var key = 'id'; | |
var csv = null; | |
var actual = parse({ csv, key }); | |
expect(actual.length).toBe(0); | |
}); | |
}); | |
/* match ids in upload with fields in data */ | |
describe('match({ ids, data })', () => { | |
it('finds matching ids in data', () => { | |
var ids = [ | |
"PA:TX:8d886222-66ae-4cb6-87dc-0b9cc3f77f8c" | |
]; | |
var data = [ | |
{ | |
id: "PA:TX:8d886222-66ae-4cb6-87dc-0b9cc3f77f8c" | |
} | |
]; | |
var items = match({ ids, data }); | |
expect(items.unmatched.length).toBe(0); | |
expect(items.matched.length).toBe(1); | |
}); | |
it('finds unmatched ids in data', () => { | |
var ids = [ | |
"PA:TX:8d886222-66ae-4cb6-87dc-0b9cc3f77f8c", | |
"PA:TX:8d886222-66ae-4cb6-87dc-no-match" | |
]; | |
var data = [ | |
{ | |
id: "PA:TX:8d886222-66ae-4cb6-87dc-0b9cc3f77f8c" | |
} | |
]; | |
var items = match({ ids, data }); | |
expect(items.unmatched.length).toBe(1); | |
expect(items.matched.length).toBe(1); | |
}); | |
}); | |
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import React, { Component, Fragment } from "react"; | |
import { connect } from "react-redux"; | |
import { | |
withStyles, | |
} from "@material-ui/core"; | |
import CloudDownload from "@material-ui/icons/CloudDownload"; | |
import CloudUpload from "@material-ui/icons/CloudUpload"; | |
import { styles } from "../../../styles/styles"; | |
import { transform, parse, match } from '../data/csv'; | |
class CSVFileHandler extends Component { | |
/* Stateless component with local and pass-through actions. */ | |
encodedDataUri = () => { | |
const array = this.props.data; | |
if (!array.length) { | |
return ''; | |
} | |
// TODO: Expose this to props as props.columns. | |
// Restrict downloaded transaction fields to the following: | |
const columns = [ 'id', 'cycleId', 'amount', 'payerId', 'payeeId' ]; | |
const csvContent = "data:text/csv;charset=utf-8," + transform({ array, columns }); | |
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURI | |
return encodeURI(csvContent); | |
} | |
safeFileName = () => { | |
return 'NOC_transactions_' + Date.now() + '_download.csv'; | |
} | |
handleCSVUpload = (event) => { | |
const files = event.target.files; | |
if (!files || files.length !== 1) { | |
return; | |
} | |
/* | |
* Use the browser's FileReader to import the contents, then delegate | |
* processing to the onload/onerror handlers. See more at | |
* https://blog.mounirmesselmeni.de/2012/11/20/reading-csv-file-with-javascript-and-html5-file-api/ | |
*/ | |
const reader = new FileReader(); | |
reader.onload = this.onFileUpload; | |
reader.onerror = this.onFileError; | |
// You can test the error handling by reassigning as {} or ''... | |
const file = files[0]; | |
try { | |
reader.readAsText(file); | |
} catch(error) { | |
this.onFileError({ | |
// Simulate onerror interface. | |
target: Object.assign({}, file, { error }) | |
}); | |
} | |
} | |
onFileUpload = (event) => { | |
const csv = event.target.result; | |
// Try to parse the incoming CSV on the `id` column. | |
const ids = parse({ csv, key: 'id' }); | |
// If there's no data, show message (CSV was empty or could not be processed) | |
if (!ids.length) { | |
this.props.openDialog({ | |
title: 'No Data Found', | |
error: 'No IDs found in the uploaded CSV file.' | |
}); | |
return; | |
} | |
// Compare incoming with current table data | |
const data = this.props.data; | |
const items = match({ ids, data }); | |
// If an item is not found in table, abort and show message (item not found, remove from CSV and try again) | |
if (items.unmatched.length) { | |
const error = [ | |
'The following IDs in the uploaded CSV file were not found in current view.', | |
'Please remove them or try another CSV file:', | |
'' | |
] | |
.concat(items.unmatched) | |
.join('\n'); | |
this.props.openDialog({ | |
title: 'IDs not found.', | |
error, | |
}); | |
return; | |
} | |
this.props.openDialog({ | |
title: 'Success.', | |
content: "Matched all IDs in the uploaded CSV", | |
}); | |
// Return matched items to the parent component. | |
this.props.onFileUpload({ items: items.matched }); | |
} | |
onFileError = (event) => { | |
const error = [ | |
'Uploaded CSV file could not be opened:', | |
`+ name: ${event.target.name}`, | |
`+ error: ${event.target.error.message}` | |
].join('\n'); | |
this.props.openDialog({ | |
title: 'IDs not found.', | |
error | |
}); | |
return; | |
} | |
render() { | |
const encodedDataUri = this.encodedDataUri(); | |
const safeFileName = this.safeFileName(); | |
return ( | |
encodedDataUri | |
? <Fragment> | |
<a href={encodedDataUri} download={safeFileName} | |
style={{ display: "inline-block", padding: "0.5em 0.75em 0.85em 0", color: "#1F1BFF" }}> | |
<CloudDownload style={{ margin: "0 0.5rem -0.25em 0" }} /> Download Transactions (CSV) | |
</a> | |
<label htmlFor="transactions-csv-upload" tabIndex="0" role="button" | |
style={{ display: "inline-block", padding: "0.5em 0.75em 0.85em 0", color: "#1F1BFF", cursor: "pointer", textDecorationLine: "underline" }}> | |
<CloudUpload style={{ margin: "0 0.5rem -0.25em 0" }} /> Upload Transactions (CSV) | |
<input type="file" id="transactions-csv-upload" accept=".csv" onChange={this.handleCSVUpload} style={{ display: "none" }} /> | |
</label> | |
</Fragment> | |
: null | |
); | |
} | |
} | |
const mapStateToProps = state => ({}); | |
export default connect( | |
mapStateToProps, | |
{} | |
)(withStyles(styles)(CSVFileHandler)); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import React, { Component } from "react"; | |
import { connect } from "react-redux"; | |
import { | |
withStyles, | |
Grid, | |
} from "@material-ui/core"; | |
import { styles } from "../../../styles/styles"; | |
import DataTable from "../../shared/DataTable"; | |
import CSVFileHandler from "../../shared/controls/CSVFileHandler"; | |
import RetryCTA from "../../shared/controls/RetryCTA"; | |
class CycleRetry extends Component { | |
/* Stateless component with local and pass-through actions. */ | |
handleCheckbox = (item, checked) => { | |
// Make a copy of the array using slice | |
const next = this.props.markedForRemoval.slice(); | |
const id = item.id; | |
var updated = false; | |
if (checked && next.indexOf(id) < 0) { | |
/* | |
* If it's checked AND it's NOT in the array, push it. | |
* push() returns new length, should be always greater than 0. | |
*/ | |
updated = next.push(id); | |
} | |
if (!checked && next.indexOf(id) > -1) { | |
/* | |
* If it's NOT checked AND it's in the array, remove it | |
* splice() returns removed item - if it's falsey, update with 1. | |
*/ | |
updated = next.splice(next.indexOf(id), 1) || 1; | |
} | |
if (updated) { | |
// Call the parent | |
this.props.markForRemoval({ items: next }); | |
} | |
} | |
onFileUpload = ({ items = [] }) => { | |
// Select checkbox for each matching id. | |
items.forEach(id => { | |
var item = { id }; | |
this.handleCheckbox(item, true); | |
}); | |
} | |
onRetry = () => { | |
var transactionIds = this.props.markedForRemoval.slice(); | |
var cycle = this.props.description; | |
var message = !transactionIds.length | |
? 'Do you want to re-send this cycle for processing?' | |
: 'Do you want to remove transaction(s) from the cycle and re-send for processing?'; | |
const retry = window.confirm(message); | |
if (retry) { | |
// Call the parent | |
this.props.onRetry({ transactionIds, cycle }); | |
} | |
} | |
onCancel = () => { | |
// Call the parent | |
this.props.onCancel(); | |
} | |
render() { | |
return ( | |
<DataTable | |
label={this.props.label} | |
description={this.props.description} | |
columns={this.props.columns} | |
data={this.props.data} | |
open={this.props.open} | |
onClose={this.props.onClose} | |
showRetryCTA={true} | |
orderBy="id" | |
handleCheckbox={this.handleCheckbox} | |
checkedItems={this.props.markedForRemoval} | |
> | |
<Grid item style={{ marginTop: '-0.5em' }}> | |
<CSVFileHandler | |
data={this.props.data} | |
onFileUpload={this.onFileUpload} | |
openDialog={this.props.openDialog} | |
/> | |
<RetryCTA | |
data={this.props.data} | |
onRetry={this.onRetry} | |
onCancel={this.onCancel} | |
/> | |
</Grid> | |
</DataTable> | |
); | |
} | |
} | |
const mapStateToProps = state => ({}); | |
export default connect( | |
mapStateToProps, | |
{} | |
)(withStyles(styles)(CycleRetry)); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import React, { Component, Fragment } from "react"; | |
import { connect } from "react-redux"; | |
import { withStyles, Grid, Typography } from "@material-ui/core"; | |
import ResponseDialog from "../../shared/controls/ResponseDialog"; | |
import Progress from "../../shared/controls/Progress"; | |
import AppButton from "../../shared/controls/AppButton"; | |
import InputEdit from "../../shared/inputs/InputEdit"; | |
import InputSelect from "../../shared/inputs/InputSelect"; | |
import DataTable from "../../shared/DataTable"; | |
import { Dialog } from '../../shared/data/dialog'; | |
import CycleDetails from "../details/CycleDetails"; | |
import CycleRetry from "../details/CycleRetry"; | |
import { checkResponse, scrubQuery, buildGraphQuery } from '../../shared/data/helpers'; | |
import { setCyclesListData, setCycleDetailsData } from "../../../redux/actions"; | |
import { styles } from "../../../styles/styles"; | |
import { isDateSupported } from '../../../utils/dom-support'; | |
import { | |
getTransactionsByCycleId, | |
removeTransactionsFromCycle, | |
retryCycle, | |
GraphRequest | |
} from "../../../utils/api"; | |
const cycleStates = [ | |
{ | |
index: 0, | |
label: 'Select', | |
value: 'SELECT' | |
}, | |
{ | |
index: 1, | |
label: 'Open', | |
value: 'OPEN' | |
}, | |
{ | |
index: 2, | |
label: 'Close', | |
value: 'CLOSE' | |
}, | |
{ | |
index: 3, | |
label: 'Failed', | |
value: 'FAILED' | |
}, | |
{ | |
index: 4, | |
label: 'Completed', | |
value: 'COMPLETED' | |
} | |
]; | |
class Cycles extends Component { | |
state = { | |
progressLoading: false, | |
progressTitle: "", | |
cyclesListOpen: false, | |
cycleDetailsOpen: false, | |
cycleDescription: '', | |
dialog: Dialog.config(), | |
errors: {}, | |
query: { | |
id: '', | |
displayId: '', | |
cycleConfigurationId: '', | |
openedDate: { | |
fromValue: '', | |
toValue: '' | |
}, | |
state: cycleStates[0].value, | |
}, | |
maxCycleDate: (new Date()).toISOString().split('T')[0], | |
markedForRemoval: [] | |
}; | |
openDialog = (options) => { | |
Dialog.open(this, options); | |
}; | |
handleChange = e => { | |
var field = e.target; | |
var name = field.name; | |
var value = scrubQuery(field.value); | |
var query = { ...this.state.query }; | |
if (/openedDate/.test(name)) { | |
var openedDate = query.openedDate; | |
/_from/.test(name) | |
? openedDate.fromValue = value | |
: openedDate.toValue = value; | |
} else { | |
query[name] = value; | |
} | |
this.setState({ query }); | |
}; | |
handleSelectChange = e => { | |
var field = e.target; | |
var name = field.name; | |
var value = field.value; | |
var query = { ...this.state.query }; | |
query[name] = value; | |
this.setState({ query }); | |
} | |
handleClose = name => { | |
// Set whoever is open to close. | |
this.setState({ | |
[name]: false | |
}); | |
}; | |
handleReset = () => { | |
var query = { ...this.state.query }; | |
Object.keys(query).forEach(key => { | |
if (/openedDate/.test(key)) { | |
query.openedDate = { | |
fromValue: '', | |
toValue: '' | |
}; | |
return; | |
} | |
if (/state/.test(key)) { | |
query.state = cycleStates[0].value; | |
return; | |
} | |
query[key] = ''; | |
}); | |
this.setState({ query }); | |
} | |
handleSubmit = e => { | |
e.preventDefault(); | |
var form = e.target; | |
var elements = Array.from(form.elements); | |
var fields = { | |
openedDate: {...this.state.query.openedDate} | |
}; | |
elements.forEach(element => { | |
var { name, value } = element; | |
if (!name) { | |
return; | |
} | |
if (/openedDate/.test(name)) { | |
var openedDate = fields.openedDate; | |
var time = Date.parse(value); | |
/_from/.test(name) | |
? openedDate.fromValue = time || '' | |
: openedDate.toValue = time ? time : openedDate.fromValue ? Date.parse(this.state.maxCycleDate) : ''; | |
// If we have at least one of the date fields, add these graphQL params. | |
if (time || openedDate.toValue) { | |
openedDate.queryType = 'date_range'; | |
openedDate.queryClause = 'must'; | |
} | |
return; | |
} | |
/* | |
* If a select element value is still the default, assign an empty string | |
* as the payload value. | |
*/ | |
fields[name] = value === 'SELECT' ? '' : value; | |
}); | |
var searchType = 'CYCLE_NOC'; | |
var queryFields = { | |
'cycles': [ | |
'id', | |
'openedDate', | |
'closedDate', | |
'cycleConfigurationId', | |
'state', | |
'displayId' | |
] | |
}; | |
var query = buildGraphQuery({ fields, searchType, queryFields }); | |
if (!query.searchTerms.length) { | |
// All fields are empty, so abort this operation. | |
return; | |
} | |
var jsonGraphQuery = String(query); | |
this.setState( | |
{ | |
progressTitle: "Getting Cycles...", | |
progressLoading: true | |
}, | |
() => { | |
GraphRequest(this.props.token, jsonGraphQuery) | |
.then(response => { | |
var data = response.data.yapSearch; | |
var message = 'Problem with graph request for cycles'; | |
var error = checkResponse({ data, path: 'cycles', message }); | |
if (error) { | |
const title = error === message | |
? 'No Data Found' | |
: 'There was a problem'; | |
this.openDialog({ | |
title, | |
error | |
}); | |
return; | |
} | |
this.setState( | |
{ | |
progressTitle: "", | |
progressLoading: false | |
}, | |
() => { | |
this.props.setCyclesListData({ results: data.cycles }); | |
this.setState({ | |
cyclesListOpen: true | |
}); | |
} | |
); | |
}) | |
.catch(err => console.log(err)); | |
}); | |
}; | |
/* API handlers */ | |
getCycleDetailsById = (cycleId) => { | |
if (!cycleId) { | |
return; | |
} | |
this.setState( | |
{ | |
progressTitle: "Getting Cycle Details...", | |
progressLoading: true | |
}, | |
() => { | |
getTransactionsByCycleId( | |
this.props.token, | |
this.props.cycleDetails.serviceUrl, | |
this.props.cycleDetails.headers, | |
cycleId) | |
.then(data => { | |
// Expect data.results | |
var message = 'Could not find transactions for cycle with ID: ' + cycleId; | |
var error = checkResponse({ data, message }); | |
if (error) { | |
const title = error === message | |
? 'No Data Found' | |
: 'There was a problem'; | |
this.openDialog({ | |
title, | |
error | |
}); | |
return; | |
} | |
this.setState( | |
{ | |
progressTitle: "", | |
progressLoading: false | |
}, | |
() => { | |
this.props.setCycleDetailsData(data); | |
this.setState({ | |
cycleDetailsOpen: true, | |
cycleDescription: this.getCycleDescription({ | |
cycleId, | |
cycles: this.props.cycles.responseData.results | |
}) | |
}); | |
} | |
); | |
}) | |
.catch(err => console.log(err)); | |
} | |
); | |
} | |
removeTransactionsFromCycle = ({ transactionIds, cycle }) => { | |
this.setState( | |
{ | |
progressTitle: "Removing Transactions...", | |
progressLoading: true | |
}, | |
() => { | |
removeTransactionsFromCycle( | |
this.props.token, | |
this.props.transactions.serviceUrl, | |
this.props.transactions.headers, | |
transactionIds) | |
.then(data => { | |
// Expect data.stateChanges | |
var message = 'Could not remove transactions: ' + transactionIds; | |
var error = checkResponse({ data, path: 'stateChanges', message }); | |
if (error) { | |
this.openDialog({ | |
title: 'There was a problem', | |
error | |
}); | |
return; | |
} | |
/* | |
* Clear out the markedForRemoval array to uncheck the checkboxes, | |
* then call back to retry the cycle. | |
*/ | |
this.setState( | |
{ | |
markedForRemoval: [] | |
}, | |
() => { | |
this.retryCycle({ cycle }); | |
} | |
); | |
}) | |
.catch(err => console.log(err)); | |
} | |
); | |
} | |
retryCycle = ({ transactionIds = [], cycle = {} }) => { | |
/* | |
* First, delegate calls to remove transactions from cycle processing, and | |
* let that method call back here. | |
*/ | |
if (transactionIds.length) { | |
this.removeTransactionsFromCycle({ transactionIds, cycle }); | |
return; | |
} | |
/* | |
* Now run the Retry step. On success, refresh the cycle from the back end. | |
*/ | |
var cycleId = cycle.id; | |
this.setState( | |
{ | |
progressTitle: "Retrying Cycle...", | |
progressLoading: true | |
}, | |
() => { | |
retryCycle( | |
this.props.token, | |
this.props.cycles.serviceUrl, | |
this.props.cycles.headers, | |
cycleId) | |
.then(data => { | |
// Expect data.currentState | |
var message = 'Could not retry cycle with ID: ' + cycleId; | |
var error = checkResponse({ data, path: 'currentState', message }); | |
if (error) { | |
this.openDialog({ | |
title: 'There was a problem', | |
error | |
}); | |
return; | |
} | |
this.openDialog({ | |
title: 'Re-try successful', | |
content: `Cycle now in ${data.currentState} state.` | |
}); | |
this.setState( | |
{ | |
progressTitle: "Reloading cycle details...", | |
progressLoading: true | |
}, | |
() => { | |
this.getCycleDetailsById(cycleId); | |
} | |
); | |
}) | |
.catch(err => console.log(err)); | |
} | |
); | |
} | |
/* Helpers */ | |
getCycleDescription = ({ cycleId, cycles = [] }) => { | |
// TODO: REFACTOR THIS TO RETURN A <COMPONENT> TO PASS IN. | |
/* | |
* Creates a small cycle description object for use in CycleDetails and | |
* CycleRetry. | |
*/ | |
var description = ''; | |
cycles.some(cycle => { | |
var done = cycle.id === cycleId; | |
if (done) { | |
// TODO: REVISE THIS DESCRIPTION OBJECT | |
// FOR EASIER CONSUMPTION BY DATA TABLE | |
description = { | |
displayId: cycle.displayId, | |
id: cycle.id, | |
state: cycle.state | |
} | |
} | |
return done; | |
}); | |
return description; | |
} | |
/* Pass props methods down to CycleRetry */ | |
onRetry = ({ transactionIds = [], cycle = {} }) => { | |
this.retryCycle({ transactionIds, cycle }); | |
} | |
markForRemoval({ items = [] }) { | |
// If we updated, set state with the new array. | |
this.setState({ | |
markedForRemoval: items | |
}); | |
} | |
onCancel = () => { | |
// Clear out the markedForRemoval array. | |
this.setState({ | |
markedForRemoval: [] | |
}); | |
} | |
/* Update */ | |
render() { | |
var cyclesData = this.props.cycles.responseData; | |
var cycles = (cyclesData && cyclesData.results) || []; | |
var cycleDetailsData = this.props.cycleDetails.responseData; | |
var cycleDetails = (cycleDetailsData && cycleDetailsData.results) || []; | |
var showRetryCTA = cycleDetails.length > 0 && this.state.cycleDescription.state === 'FAILED'; | |
var { classes } = this.props; | |
var openedDateAttributes = isDateSupported() | |
? { | |
placeholder: '', | |
helperText: 'Choose date with date picker control.', | |
} | |
: { | |
placeholder: 'Enter date as yyyy-mm-dd', | |
helperText: 'Hint: Enter May 22 2019 as 2019-05-22', | |
inputLabel: 'Opened Date (yyyy-mm-dd)' | |
}; | |
return ( | |
<Fragment> | |
<form onSubmit={this.handleSubmit}> | |
<Typography className={classes.configEditorHeading}>Search Cycles by</Typography> | |
<Grid container spacing={24} alignContent="center" justify="center"> | |
<Grid item xs={6} className={classes.gridItem}> | |
<InputEdit | |
inputLabel="Cycle ID" | |
helperText="Hint: ST:CCL:37af8023-a1c8-492e-b768-04d1d4f1c5f7" | |
placeholder="Enter a Cycle ID" | |
inputErrors={this.state.errors} | |
inputName="id" | |
inputValue={this.state.query.id} | |
handleChange={this.handleChange} | |
/> | |
</Grid> | |
<Grid item xs={6} className={classes.gridItem}> | |
<InputEdit | |
inputLabel="Cycle Configuration ID" | |
helperText="Hint: ST:CCF:83c831a3-a4cd-4ee4-8f31-2c4957f78565" | |
placeholder="Enter a Cycle Configuration ID" | |
inputErrors={this.state.errors} | |
inputName="cycleConfigurationId" | |
inputValue={this.state.query.cycleConfigurationId} | |
handleChange={this.handleChange} | |
/> | |
</Grid> | |
<Grid item xs={6} className={classes.gridItem}> | |
<InputEdit | |
inputLabel="Cycle Display ID" | |
helperText="Hint: PO201907190247" | |
placeholder="Enter a Cycle Display ID" | |
inputErrors={this.state.errors} | |
inputName="displayId" | |
inputValue={this.state.query.displayId} | |
handleChange={this.handleChange} | |
/> | |
</Grid> | |
<Grid item xs={6} className={classes.gridItem}> | |
<InputSelect | |
inputLabel="Cycle State" | |
inputName="state" | |
inputValue={this.state.query.state} | |
inputList={cycleStates} | |
onChange={this.handleSelectChange} | |
/> | |
</Grid> | |
<Grid item xs={6} className={classes.gridItem}> | |
<InputEdit | |
inputLabel='Opened Date From' | |
placeholder={openedDateAttributes.placeholder} | |
helperText={openedDateAttributes.helperText} | |
inputErrors={this.state.errors} | |
inputName="openedDate_from" | |
inputType="date" | |
inputValue={this.state.query.openedDate.fromValue} | |
inputMax={this.state.maxCycleDate} | |
handleChange={this.handleChange} | |
/> | |
</Grid> | |
<Grid item xs={6} className={classes.gridItem}> | |
<InputEdit | |
inputLabel='Opened Date To' | |
placeholder={openedDateAttributes.placeholder} | |
helperText={openedDateAttributes.helperText} | |
inputErrors={this.state.errors} | |
inputName="openedDate_to" | |
inputType="date" | |
inputValue={this.state.query.openedDate.toValue} | |
inputMax={this.state.maxCycleDate} | |
handleChange={this.handleChange} | |
/> | |
</Grid> | |
<Grid item xs={6} style={{ display: "flex", justifyContent: "flex-end" }}> | |
<AppButton | |
color="default" | |
buttonText="Cancel" | |
type="reset" | |
onClick={this.handleReset} | |
/> | |
</Grid> | |
<Grid item xs={6}> | |
<AppButton | |
color="primary" | |
buttonText="Search" | |
type="submit" | |
/> | |
</Grid> | |
</Grid> | |
{/* SHOW LIST OF CYCLES FOR A CHOSEN DATE */} | |
<DataTable | |
label={this.props.cycles.label} | |
columns={this.props.cycles.columns} | |
data={cycles} | |
open={this.state.cyclesListOpen} | |
onClose={() => this.handleClose("cyclesListOpen")} | |
apiCall={this.getCycleDetailsById} | |
orderBy="displayId" | |
/> | |
{/* SHOW DETAIL (LIST OF TRANSACTIONS) FOR A CHOSEN CYCLE */} | |
{showRetryCTA | |
? <CycleRetry | |
// label={this.props.cycleDetails.label} | |
description={this.state.cycleDescription} | |
columns={this.props.cycleDetails.columns} | |
data={cycleDetails} | |
open={this.state.cycleDetailsOpen} | |
onClose={() => this.handleClose("cycleDetailsOpen")} | |
onRetry={this.onRetry.bind(this)} | |
orderBy="id" | |
markForRemoval={this.markForRemoval.bind(this)} | |
markedForRemoval={this.state.markedForRemoval} | |
onCancel={this.onCancel} | |
openDialog={this.openDialog} | |
/> | |
: <CycleDetails | |
// label={this.props.cycleDetails.label} | |
description={this.state.cycleDescription} | |
columns={this.props.cycleDetails.columns} | |
data={cycleDetails} | |
open={this.state.cycleDetailsOpen} | |
onClose={() => this.handleClose("cycleDetailsOpen")} | |
orderBy="id" | |
/> | |
} | |
<Progress | |
isProgressLoading={this.state.progressLoading} | |
progressTitle={this.state.progressTitle} | |
/> | |
{/* Show messages to the User */} | |
<ResponseDialog | |
open={this.state.dialog.open} | |
title={this.state.dialog.title} | |
error={this.state.dialog.error} | |
content={this.state.dialog.content} | |
buttonText={this.state.dialog.buttonText} | |
onClose={this.state.dialog.onClose} | |
buttonId={this.state.dialog.buttonId} | |
/> | |
</form> | |
</Fragment> | |
); | |
} | |
} | |
const mapStateToProps = state => ({ | |
cycles: state.cycles, | |
cycleDetails: state.cycleDetails, | |
transactions: state.transactions, | |
token: state.token | |
}); | |
export default connect( | |
mapStateToProps, | |
{ | |
setCyclesListData, | |
setCycleDetailsData | |
} | |
)(withStyles(styles)(Cycles)); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment