| 
									
										
										
										
											2018-03-29 15:42:43 -05:00
										 |  |  | // License: LGPL-3.0-or-later | 
					
						
							| 
									
										
										
										
											2018-03-25 13:30:42 -04:00
										 |  |  | // npm | 
					
						
							|  |  |  | const h = require('snabbdom/h') | 
					
						
							|  |  |  | const R = require('ramda') | 
					
						
							|  |  |  | const snabbdom = require('snabbdom') | 
					
						
							|  |  |  | const formSerialize = require('form-serialize') | 
					
						
							|  |  |  | const flyd = require('flyd') | 
					
						
							|  |  |  | const render = require('ff-core/render') | 
					
						
							|  |  |  | flyd.flatMap = R.curry(require('flyd/module/flatmap')) | 
					
						
							|  |  |  | flyd.filter = require('flyd/module/filter') | 
					
						
							|  |  |  | flyd.mergeAll = require('flyd/module/mergeall') | 
					
						
							|  |  |  | flyd.lift = R.curry(require('flyd/module/lift')) | 
					
						
							|  |  |  | flyd.switchLatest = require('flyd/module/switchlatest') | 
					
						
							|  |  |  | const modal = require('ff-core/modal') | 
					
						
							|  |  |  | const wizard = require('ff-core/wizard') | 
					
						
							|  |  |  | const notification = require('ff-core/notification') | 
					
						
							|  |  |  | const button = require('ff-core/button') | 
					
						
							|  |  |  | // local | 
					
						
							|  |  |  | const request = require('../../../common/request') | 
					
						
							|  |  |  | const fileInputStream = require('../../../common/file-input-stream') | 
					
						
							|  |  |  | const uploadFile = require('../../../common/direct-to-s3-upload.es6') | 
					
						
							|  |  |  | const fields = require('./regex-header-matchers') | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | // The import modal UI | 
					
						
							|  |  |  | // Upload a CSV, match up the columns, and import! | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | // open the real import modal with appl.open_modal('importModal') | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | function init() { | 
					
						
							|  |  |  |   var state = { | 
					
						
							|  |  |  |     fileUpload$: flyd.stream() | 
					
						
							|  |  |  |   , submitFields$: flyd.stream() | 
					
						
							|  |  |  |   , submitImport$: flyd.stream() | 
					
						
							|  |  |  |   , fileUploadEmail$: flyd.stream() | 
					
						
							|  |  |  |   , error$: flyd.stream() // unused for now | 
					
						
							|  |  |  |   } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   const fileContents$ = flyd.flatMap(ev => fileInputStream(ev.currentTarget), state.fileUpload$) | 
					
						
							|  |  |  |   state.uploadInput$ = flyd.map(ev => ev.currentTarget, state.fileUpload$) | 
					
						
							|  |  |  |    | 
					
						
							|  |  |  |   // Find the first line of the CSV, which is the headers row. Get the second | 
					
						
							|  |  |  |   // result from the match function, as that will be the parenthesized match | 
					
						
							|  |  |  |   // group. | 
					
						
							|  |  |  |   const headers$ = flyd.map(txt => txt.match(/^(.*)(\r?\n|\r)/)[1].split(','), fileContents$) | 
					
						
							|  |  |  |   state.rowCount$ = flyd.map(txt => txt.match(/\r?\n|\r/g).length, fileContents$) | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   // Stream of matched table/column fields based on running regexes over the haders of their files  | 
					
						
							|  |  |  |   // The matches are stored as pairs of [type, field], eg ['Supporter, 'First Name'] | 
					
						
							|  |  |  |   state.matchedHeaders$ = flyd.map(findHeaderMatches, headers$) | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   // state.submitImport$ is passed the current component state, and we just want a stream of input node objects for uploadFile | 
					
						
							|  |  |  |   const uploaded$ = flyd.flatMap(uploadFile, state.submitImport$) | 
					
						
							|  |  |  |    | 
					
						
							|  |  |  |   // The matched headers with a simplified data structure to post to the server | 
					
						
							|  |  |  |   // data structure is like {header_name => match_name} -- eg {'Donation Amount' => 'donation.amount'} | 
					
						
							|  |  |  |   state.headerData$ = flyd.map(ev => formSerialize(ev.currentTarget, {hash: true}), state.submitFields$) | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   const importResp$ = flyd.switchLatest(flyd.lift(postImport, state.headerData$, uploaded$)) | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   const emailFile$ = R.compose( | 
					
						
							|  |  |  |     flyd.flatMap(uploadFile) | 
					
						
							|  |  |  |   , flyd.map(ev => {ev.preventDefault(); return ev.currentTarget.querySelector('input')}) | 
					
						
							|  |  |  |   )(state.fileUploadEmail$) | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   state.loading$ = flyd.mergeAll([ | 
					
						
							|  |  |  |     flyd.map(()=> true,  state.submitImport$) // start loading | 
					
						
							|  |  |  |   , flyd.map(()=> false, importResp$)         // stop loading | 
					
						
							|  |  |  |   , flyd.map(()=> true,  state.fileUploadEmail$) | 
					
						
							|  |  |  |   , flyd.map(()=> false, emailFile$) | 
					
						
							|  |  |  |   ]) | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   const notify$ = flyd.map( | 
					
						
							|  |  |  |     ()=> 'Your import was successfully initiated. Feel free to upload additional files.' | 
					
						
							|  |  |  |   , emailFile$ | 
					
						
							|  |  |  |   ) | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   // All streams that cause the wizard to advance | 
					
						
							|  |  |  |   const wizardStep$ = flyd.mergeAll([ | 
					
						
							|  |  |  |     flyd.stream(0) | 
					
						
							|  |  |  |   , flyd.map(() => 1, state.fileUpload$) | 
					
						
							|  |  |  |   , flyd.map(() => 2, state.submitFields$) | 
					
						
							|  |  |  |   ]) | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   const wizardCompleted$ = flyd.map(()=> true, importResp$) | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   state.modalID$ = flyd.stream() | 
					
						
							|  |  |  |   const jump$ = flyd.stream() | 
					
						
							|  |  |  |   state.wizard = wizard.init({currentStep$: wizardStep$, isCompleted$: wizardCompleted$}) | 
					
						
							|  |  |  |   state.notification = notification.init({message$: notify$}) | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   // XXX using vanilla JS for the initial modal open action. This can be replaced with Flyd/Vdom when the CRM table meta is in vdom | 
					
						
							|  |  |  |   var btnSuper = document.querySelector('.js-importButton') | 
					
						
							|  |  |  |   if(btnSuper) btnSuper.addEventListener('click', ev => state.modalID$('importModal'))  | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   return state | 
					
						
							|  |  |  | } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | // post to /imports after the file is uploaded to S3 | 
					
						
							|  |  |  | const postImport = R.curry((headers, file) => { | 
					
						
							|  |  |  |   return flyd.map(R.prop('body'), request({ | 
					
						
							|  |  |  |     method: 'post' | 
					
						
							|  |  |  |   , path: `/nonprofits/${app.nonprofit_id}/imports` | 
					
						
							|  |  |  |   , send: {file_uri: file.uri, header_matches: headers} | 
					
						
							|  |  |  |   }).load) | 
					
						
							|  |  |  | }) | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | // Maps over the header strings. | 
					
						
							|  |  |  | // Return an array of pairs of matches like [tableName, fieldName] using | 
					
						
							|  |  |  | // regexes (from the fields object above) based on the column headers from the CSV | 
					
						
							|  |  |  | const findHeaderMatches = | 
					
						
							|  |  |  |   R.map( | 
					
						
							|  |  |  |     name => ({ | 
					
						
							|  |  |  |       name: name | 
					
						
							|  |  |  |     , match: R.find(f => R.test(f.regex, name), fields) | 
					
						
							|  |  |  |     }) | 
					
						
							|  |  |  |   ) | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | function dontLetThemMessItUp(state) { | 
					
						
							|  |  |  |   return modal({ | 
					
						
							|  |  |  |     thisID: 'importDontLetThemDoIt' | 
					
						
							|  |  |  |   , id$: state.modalID$ | 
					
						
							|  |  |  |   , title: 'New Import' | 
					
						
							|  |  |  |   , className: 'modal--flush' | 
					
						
							|  |  |  |   , body: dontLetThemBody(state) | 
					
						
							|  |  |  |   }) | 
					
						
							|  |  |  | } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | function dontLetThemBody(state) { | 
					
						
							|  |  |  |   return h('div', [ | 
					
						
							|  |  |  |     h('p',  'Upload a spreadsheet to get your import rolling. Imports will take 1-3 days depending on the data.') | 
					
						
							|  |  |  |   , h('p',  'You can generally import any donor and supporter information along with their donation amounts, dates, designations, etc.') | 
					
						
							|  |  |  |   , h('p',  'You will receive an email followup once the import is complete or if there were any problems with the data.') | 
					
						
							|  |  |  |   , h('form', {on: {submit: state.fileUploadEmail$}}, [ | 
					
						
							|  |  |  |       h('input',  {props: {type: 'file', name: 'file'}}) | 
					
						
							|  |  |  |     , h('hr') | 
					
						
							|  |  |  |     , button({loading$: state.loading$, error$: state.error$}) | 
					
						
							|  |  |  |     ]) | 
					
						
							|  |  |  |   ]) | 
					
						
							|  |  |  | } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | function view(state) { | 
					
						
							|  |  |  |   var wiz = wizard.view(R.merge(state.wizard, { | 
					
						
							|  |  |  |     steps: [ | 
					
						
							|  |  |  |       { name: 'Upload', body: uploadStep(state) } | 
					
						
							|  |  |  |     , { name: 'Fields', body: fieldsStep(state) } | 
					
						
							|  |  |  |     , { name: 'Import', body: importStep(state) } | 
					
						
							|  |  |  |     ] | 
					
						
							|  |  |  |   , followup: finishedStep(state) | 
					
						
							|  |  |  |   })) | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   return h('div.import', [ | 
					
						
							|  |  |  |     modal({ | 
					
						
							|  |  |  |       thisID: 'importModal' | 
					
						
							|  |  |  |     , id$: state.modalID$ | 
					
						
							|  |  |  |     , title: 'New Import' | 
					
						
							|  |  |  |     , noPad: true | 
					
						
							|  |  |  |     , className: 'modal--flush' | 
					
						
							|  |  |  |     , body: wiz | 
					
						
							|  |  |  |     }) | 
					
						
							|  |  |  |   , dontLetThemMessItUp(state) | 
					
						
							|  |  |  |   , notification.view(state.notification) | 
					
						
							|  |  |  |   ]) | 
					
						
							|  |  |  | } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | const finishedStep = state => | 
					
						
							|  |  |  |   h('div', [ | 
					
						
							|  |  |  |     h('p.u-bold.u-color--green', 'Your import has successfully started.') | 
					
						
							|  |  |  |   , h('p', "It'll take a few minutes to complete everything.") | 
					
						
							|  |  |  |   , h('p', ["We'll send a notification message to your email at ", h('span.u-bold', app.user.email), " as soon as it's done."]) | 
					
						
							|  |  |  |   , h('hr') | 
					
						
							|  |  |  |   , h('div.u-centered', [ h('button.button', {on: {click: [state.modalID$, false]}}, 'Close') ]) | 
					
						
							|  |  |  |   ]) | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | const uploadStep = state => | 
					
						
							|  |  |  |   h('div', [ | 
					
						
							|  |  |  |     h('p.u-bold', "First, let's upload a CSV file with the supporter and donation data you'd like to import. ") | 
					
						
							|  |  |  |   , h('p', 'Make sure your file has column headers in the first row.') | 
					
						
							|  |  |  |   , h('hr') | 
					
						
							|  |  |  |   , h('form', [ | 
					
						
							|  |  |  |       h('input', {on: {change: state.fileUpload$}, props: {type: 'file', name: 'file'}}) | 
					
						
							|  |  |  |     ]) | 
					
						
							|  |  |  |   ]) | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | // Modal for the user to match up CSV headers with database columns | 
					
						
							|  |  |  | function fieldsStep(state) { | 
					
						
							|  |  |  |   if(!state.matchedHeaders$()) return h('div') | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  |   return h('form', { | 
					
						
							|  |  |  |     on: {submit: ev => {ev.preventDefault(); state.submitFields$(ev)}} | 
					
						
							|  |  |  |   }, [ | 
					
						
							|  |  |  |     h('p', "We've automatically detected your CSV headers. Please match up your file's column headers with our available fields.")  | 
					
						
							|  |  |  |   , h('table.table', [ | 
					
						
							|  |  |  |       h('thead', h('tr', [h('td', 'CSV Column'), h('td', 'Import As...')])) | 
					
						
							|  |  |  |     , h('tbody', R.map(colSelectRow, state.matchedHeaders$())) | 
					
						
							|  |  |  |     ]) | 
					
						
							|  |  |  |   , h('hr') | 
					
						
							|  |  |  |   , h('div.u-centered', [ | 
					
						
							|  |  |  |       h('button.button', 'Next') | 
					
						
							|  |  |  |     ]) | 
					
						
							|  |  |  |   ]) | 
					
						
							|  |  |  | } | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | const colSelectRow = header => | 
					
						
							|  |  |  |   h('tr', [ | 
					
						
							|  |  |  |     h('td', [h('strong', header.name)]) | 
					
						
							|  |  |  |   , h('td', [h('i.fa.fa-long-arrow-right')]) | 
					
						
							|  |  |  |   , h('td.u-padding--0', [ | 
					
						
							|  |  |  |       h('select.u-margin--0.u-inlineBlock.u-width--full.u-marginY--5' | 
					
						
							|  |  |  |       , { props: {name: header.name} } | 
					
						
							|  |  |  |       , R.concat( | 
					
						
							|  |  |  |           [ // Default options for every field | 
					
						
							|  |  |  |             h('option', {props: {selected: !header.match, value: ''}}, 'Select Field') | 
					
						
							|  |  |  |           , h('option', {props: {value: ''}}, 'Ignore') | 
					
						
							|  |  |  |           , h('option', {props: {value: 'custom_field'}}, 'New Custom Field') | 
					
						
							|  |  |  |           ] | 
					
						
							|  |  |  |         , R.map(fieldOption(header), fields) | 
					
						
							|  |  |  |         ) | 
					
						
							|  |  |  |       ) | 
					
						
							|  |  |  |     ]) | 
					
						
							|  |  |  |   ]) | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | const fieldOption = header => field => | 
					
						
							|  |  |  |   h('option', { | 
					
						
							|  |  |  |     props: { | 
					
						
							|  |  |  |       value: field.import_key | 
					
						
							|  |  |  |     , selected: header.match && header.match.name === field.name | 
					
						
							|  |  |  |     } | 
					
						
							|  |  |  |   }, field.name ) | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | const importStep = state => | 
					
						
							|  |  |  |   h('div', [ | 
					
						
							|  |  |  |     h('p', ['We will be importing the following data from ', h('strong', (state.rowCount$()-1) + ' rows'), ': ']) | 
					
						
							|  |  |  |   , h('p.u-bold', R.join(', ', R.map(obj => obj.name, (state.matchedHeaders$() || [])))) | 
					
						
							|  |  |  |   , h('p', "If this looks good to you, hit Submit to get the import rolling.") | 
					
						
							|  |  |  |   , h('p', "Note that the import can always be undone later.") | 
					
						
							|  |  |  |   , h('form.u-centered', { | 
					
						
							|  |  |  |       on: { submit: ev => { ev.preventDefault(); state.submitImport$(state.uploadInput$())}} | 
					
						
							|  |  |  |     }, [ button({loading$: state.loading$, error$: state.error$}) ]) | 
					
						
							|  |  |  |   ]) | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | // -- Render to the page | 
					
						
							|  |  |  | 
 | 
					
						
							|  |  |  | var container = document.querySelector('#js-vdomParty') | 
					
						
							|  |  |  | const patch = snabbdom.init([ | 
					
						
							|  |  |  |   require('snabbdom/modules/eventlisteners') | 
					
						
							|  |  |  | , require('snabbdom/modules/class') | 
					
						
							|  |  |  | , require('snabbdom/modules/props') | 
					
						
							|  |  |  | , require('snabbdom/modules/style') | 
					
						
							|  |  |  | ]) | 
					
						
							|  |  |  | render({state: init(), view, container, patch}) | 
					
						
							|  |  |  | 
 |