// License: LGPL-3.0-or-later // 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('/rails/active_storage/direct_uploads'), 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, blob) => { return flyd.map(R.prop('body'), request({ method: 'post' , path: `/nonprofits/${app.nonprofit_id}/imports` , send: {import_file: blob.signed_id, 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})