262 lines
9 KiB
JavaScript
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})
|
|
|