Skip to content

Instantly share code, notes, and snippets.

@dfkaye
Last active August 2, 2019 16:44
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save dfkaye/813b17c70a2a93f086832732bd090599 to your computer and use it in GitHub Desktop.
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
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;
}
// 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))
}
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);
});
});
});
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));
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));
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