houdini/app/javascript/legacy/nonprofits/supporters/import/index.es6
2020-09-01 21:32:32 -05:00

262 lines
9 KiB
JavaScript

// 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})