basic custom email template editor, model and preview endpoint.

This commit is contained in:
fiatjaf 2018-09-14 02:43:20 +00:00 committed by Cole
parent 0156b05d4d
commit 0a2f727a3a
14 changed files with 1069 additions and 682 deletions

View File

@ -34,6 +34,7 @@ unicodecsv = "==0.14.1"
flask-migrate = "==2.2.1"
premailer = "==3.2.0"
ptvsd = "==4.1.2"
pystache = "*"
[pipenv]
allow_prereleases = true

7
Pipfile.lock generated
View File

@ -292,6 +292,13 @@
"index": "pypi",
"version": "==4.1.2"
},
"pystache": {
"hashes": [
"sha256:f7bbc265fb957b4d6c7c042b336563179444ab313fb93a719759111eabd3b85a"
],
"index": "pypi",
"version": "==0.5.4"
},
"python-dateutil": {
"hashes": [
"sha256:1adb80e7a782c12e52ef9a8182bebeb73f1d7e24e374397af06fb4956c8dc5c0",

View File

@ -1,3 +1,5 @@
import datetime
from urllib.parse import urljoin
from flask import request, jsonify, g
@ -8,7 +10,7 @@ from formspree.stuff import DB
from formspree.utils import jsonerror, IS_VALID_EMAIL
from .helpers import referrer_to_path, sitewide_file_check, remove_www, \
referrer_to_baseurl
from .models import Form, Submission
from .models import Form, Submission, EmailTemplate
@login_required
@ -104,9 +106,7 @@ def get(hashid):
if not form:
return jsonerror(404, {'error': "Form not found."})
for cont in form.controllers:
if cont.id == current_user.id: break
else:
if not form.controlled_by(current_user):
return jsonerror(401, {'error': "You do not control this form."})
submissions, fields = form.submissions_with_fields()
@ -193,6 +193,52 @@ def submission_delete(hashid, submissionid):
return jsonify({'ok': True})
@login_required
def custom_template_set(hashid):
form = Form.get_with_hashid(hashid)
if not form:
return jsonerror(404, {'error': "Form not found."})
if not form.controlled_by(current_user):
return jsonerror(401, {'error': "You do not control this form."})
# TODO catch render exception before deploying
try:
pass
except:
return jsonerror(406, {'error': "Failed to render. The template has errors."})
print(form.template)
DB.session.add(form)
DB.session.commit()
return jsonify({'ok': True})
@login_required
def custom_template_preview_render():
if not current_user.has_feature('whitelabel'):
return jsonerror(402, {'error': "Please upgrade your account."})
template = EmailTemplate.temporary(
style=request.get_json()['style'],
body=request.get_json()['body']
)
return template.render_body(
data={
'name': 'Irwin Jones',
'_replyto': 'i.jones@example.com',
'message': 'Hello!\n\nThis is a preview message!'
},
host='example.com/',
keys=['name', '_replyto', 'message'],
now=datetime.datetime.utcnow().strftime('%I:%M %p UTC - %d %B %Y'),
unconfirm_url='#'
)
@login_required
def sitewide_check():
email = request.get_json().get('email')

View File

@ -2,6 +2,7 @@ import hmac
import random
import hashlib
import datetime
import pystache
from flask import url_for, render_template, render_template_string, g
from sqlalchemy.sql import table
@ -11,6 +12,7 @@ from sqlalchemy.ext.mutable import MutableDict
from sqlalchemy import func
from werkzeug.datastructures import ImmutableMultiDict, \
ImmutableOrderedMultiDict
from premailer import transform
from formspree import settings
from formspree.stuff import DB, redis_store, TEMPLATES
@ -43,6 +45,7 @@ class Form(DB.Model):
owner = DB.relationship('User') # direct owner, defined by 'owner_id'
# this property is basically useless. use .controllers
template = DB.relationship('EmailTemplate', uselist=False, back_populates='form')
submissions = DB.relationship('Submission',
backref='form', lazy='dynamic', order_by=lambda: Submission.id.desc())
@ -106,6 +109,16 @@ class Form(DB.Model):
.filter(Form.id == self.id)
return by_email.union(by_creation)
@property
def features(self):
return set.union(*[cont.features for cont in self.controllers])
def controlled_by(self, user):
for cont in self.controllers:
if cont.id == user.id:
return True
return False
def has_feature(self, feature):
c = [user for user in self.controllers if user.has_feature(feature)]
return len(c) > 0
@ -126,6 +139,8 @@ class Form(DB.Model):
'counter': self.counter,
'email': self.email,
'host': self.host,
'template': self.template,
'features': {f: True for f in self.features},
'confirm_sent': self.confirm_sent,
'confirmed': self.confirmed,
'disabled': self.disabled,
@ -274,6 +289,13 @@ class Form(DB.Model):
text = render_template('email/form.txt',
data=data, host=self.host, keys=keys, now=now,
unconfirm_url=unconfirm)
# if there's a custom email template we should use it
if self.template and self.owner.has_feature('whitelabel'):
html = self.template.render_body(
data=data, host=self.host, keys=keys, now=now,
unconfirm_url=unconfirm)
# check if the user wants a new or old version of the email
if format == 'plain':
html = render_template('email/plain_form.html',
@ -486,6 +508,52 @@ class Form(DB.Model):
return True
class EmailTemplate(DB.Model):
__tablename__ = 'email_templates'
id = DB.Column(DB.Integer, primary_key=True)
form_id = DB.Column(
DB.Integer, DB.ForeignKey('forms.id'),
unique=True, nullable=False
)
subject = DB.Column(DB.Text, nullable=False)
from_name = DB.Column(DB.Text, nullable=False)
style = DB.Column(DB.Text, nullable=False)
body = DB.Column(DB.Text, nullable=False)
form = DB.relationship('Form', back_populates='template')
def __init__(self, form_id):
self.submitted_at = datetime.datetime.utcnow()
self.form_id = form_id
def __repr__(self):
return '<Email Template %s, form=%s>' % \
(self.id or 'with an id to be assigned', self.form_id)
@classmethod
def temporary(cls, style, body):
t = cls(0)
t.style = style
t.body = body
return t
def render_subject(self, data):
return pystache.render(self.subject, data)
def render_body(self, data, host, keys, now, unconfirm_url):
data.update({
'_fields': [{'field_name': f, 'field_value': data[f]} for f in keys],
'_time': now,
'_host': host
})
html = pystache.render(self.body, data)
styled = '<style>' + self.style + '</style>' + html
inlined = transform(styled)
suffixed = inlined + '''<table width="100%"><tr><td>You are receiving this because you confirmed this email address on <a href="{service_url}">{service_name}</a>. If you don't remember doing that, or no longer wish to receive these emails, please remove the form on {host} or <a href="{unconfirm_url}">click here to unsubscribe</a> from this endpoint.</td></tr></table>'''.format(service_url=settings.SERVICE_URL, service_name=settings.SERVICE_NAME, host=host, unconfirm_url=unconfirm_url)
return suffixed
class Submission(DB.Model):
__tablename__ = 'submissions'

View File

@ -496,9 +496,7 @@ def export_submissions(hashid, format=None):
return jsonerror(402, {'error': "Please upgrade your account."})
form = Form.get_with_hashid(hashid)
for cont in form.controllers:
if cont.id == current_user.id: break
else:
if not form.controlled_by(current_user):
return abort(401)
submissions, fields = form.submissions_with_fields()

View File

@ -1,675 +0,0 @@
/** @format */
const toastr = window.toastr
const fetch = window.fetch
const React = require('react')
const {Route, Link, NavLink, Redirect} = require('react-router-dom')
const CodeMirror = require('react-codemirror2')
const cs = require('class-set')
require('codemirror/mode/xml/xml')
require('codemirror/mode/javascript/javascript')
const Portal = require('../Portal')
module.exports = class FormPage extends React.Component {
constructor(props) {
super(props)
this.state = {
form: null
}
this.fetchForm = this.fetchForm.bind(this)
}
async componentDidMount() {
this.fetchForm()
}
render() {
let hashid = this.props.match.params.hashid
return (
<>
<Portal to=".menu .item:nth-child(2)">
<Link to="/forms">Your forms</Link>
</Portal>
<Portal to="#header .center">
<h1>Form Details</h1>
</Portal>
<Route
exact
path={`/forms/${hashid}`}
render={() => <Redirect to={`/forms/${hashid}/submissions`} />}
/>
{this.state.form && (
<>
<h4 className="tabs">
<NavLink
to={`/forms/${hashid}/integration`}
activeStyle={{color: 'inherit', cursor: 'normal'}}
>
Integration
</NavLink>
<NavLink
to={`/forms/${hashid}/submissions`}
activeStyle={{color: 'inherit', cursor: 'normal'}}
>
Submissions
</NavLink>
<NavLink
to={`/forms/${hashid}/settings`}
activeStyle={{color: 'inherit', cursor: 'normal'}}
>
Settings
</NavLink>
</h4>
<Route
path="/forms/:hashid/integration"
render={() => (
<FormIntegration
form={this.state.form}
onUpdate={this.fetchForm}
/>
)}
/>
<Route
path="/forms/:hashid/submissions"
render={() => (
<FormSubmissions
form={this.state.form}
onUpdate={this.fetchForm}
/>
)}
/>
<Route
path="/forms/:hashid/settings"
render={() => (
<FormSettings
form={this.state.form}
history={this.props.history}
onUpdate={this.fetchForm}
/>
)}
/>
</>
)}
</>
)
}
async fetchForm() {
let hashid = this.props.match.params.hashid
try {
let resp = await fetch(`/api-int/forms/${hashid}`, {
credentials: 'same-origin',
headers: {Accept: 'application/json'}
})
let r = await resp.json()
if (!resp.ok || r.error) {
toastr.warning(
r.error ? `Error fetching form: ${r.error}` : 'Error fetching form.'
)
return
}
this.setState({form: r})
} catch (e) {
console.error(e)
toastr.error(`Failed to fetch form, see the console for more details.`)
}
}
}
class FormIntegration extends React.Component {
constructor(props) {
super(props)
this.changeTab = this.changeTab.bind(this)
this.state = {
activeTab: 'HTML',
availableTabs: ['HTML', 'AJAX']
}
}
render() {
let {form} = this.props
var codeSample
var modeSample
switch (this.state.activeTab) {
case 'HTML':
modeSample = 'xml'
codeSample = `<form
action="${form.url}"
method="POST"
>
<label>
Your email:
<input type="text" name="_replyto">
</label>
<label>
Your message:
<textarea name="message"></textarea>
</label>
<!-- your other form fields go here -->
<button type="submit">Send</button>
</form>`
break
case 'AJAX':
modeSample = 'javascript'
codeSample = `// There should be an HTML form elsewhere on the page. See the "HTML" tab.
var form = document.querySelector('form')
var data = new FormData(form)
var req = new XMLHttpRequest()
req.open(form.method, form.action)
req.send(data)`
break
}
var integrationSnippet
if (this.state.activeTab === 'AJAX' && !form.captcha_disabled) {
integrationSnippet = (
<div className="integration-nocode CodeMirror cm-s-oceanic-next">
<p>Want to submit your form through AJAX?</p>
<p>
<Link to={`/forms/${form.hashid}/settings`}>Disable reCAPTCHA</Link>{' '}
for this form to make it possible!
</p>
</div>
)
} else {
integrationSnippet = (
<CodeMirror.UnControlled
value={codeSample}
options={{
theme: 'oceanic-next',
mode: modeSample,
viewportMargin: Infinity
}}
/>
)
}
return (
<>
<div className="col-1-1">
<div className="container">
<div className="integration">
<p>
Paste this code in your HTML, modifying it according to your
needs:
</p>
<div className="integration-tabs">
{this.state.availableTabs.map(tabName => (
<div
key={tabName}
data-tab={tabName}
onClick={this.changeTab}
className={cs({active: this.state.activeTab === tabName})}
>
{tabName}
</div>
))}
</div>
{integrationSnippet}
</div>
</div>
</div>
</>
)
}
changeTab(e) {
e.preventDefault()
this.setState({activeTab: e.target.dataset.tab})
}
}
class FormSubmissions extends React.Component {
constructor(props) {
super(props)
this.deleteSubmission = this.deleteSubmission.bind(this)
this.showExportButtons = this.showExportButtons.bind(this)
this.state = {
exporting: false
}
}
render() {
let {form} = this.props
return (
<div className="col-1-1 submissions-col">
<FormDescription prefix="Submissions for" form={form} />
{form.submissions.length ? (
<>
<table className="submissions responsive">
<thead>
<tr>
<th>Submitted at</th>
{form.fields
.slice(1 /* the first field is 'date' */)
.map(f => (
<th key={f}>{f}</th>
))}
<th />
</tr>
</thead>
<tbody>
{form.submissions.map(s => (
<tr id={`submission-${s.id}`} key={s.id}>
<td id={`p-${s.id}`} data-label="Submitted at">
{new Date(Date.parse(s.date))
.toString()
.split(' ')
.slice(0, 5)
.join(' ')}
</td>
{form.fields
.slice(1 /* the first field is 'date' */)
.map(f => (
<td data-label={f} key={f}>
<pre>{s[f]}</pre>
</td>
))}
<td>
<button
className="no-border"
data-sub={s.id}
onClick={this.deleteSubmission}
>
<i className="fa fa-trash-o delete" />
</button>
</td>
</tr>
))}
</tbody>
</table>
<div className="container">
<div className="row">
{this.state.exporting ? (
<div className="col-1-1 right">
<a
target="_blank"
className="button"
style={{marginRight: '5px'}}
href={`/forms/${form.hashid}.json`}
>
Export as JSON
</a>
<a
target="_blank"
className="button"
href={`/forms/${form.hashid}.csv`}
>
Export as CSV
</a>
</div>
) : (
<div className="col-1-1 right">
<button
onClick={this.showExportButtons}
href={`/forms/${form.hashid}.json`}
>
Export
</button>
</div>
)}
</div>
</div>
</>
) : (
<h3>No submissions archived yet.</h3>
)}
</div>
)
}
showExportButtons(e) {
e.preventDefault()
this.setState({exporting: true})
}
async deleteSubmission(e) {
e.preventDefault()
let subid = e.currentTarget.dataset.sub
try {
let resp = await fetch(
`/api-int/forms/${this.props.form.hashid}/submissions/${subid}`,
{
method: 'DELETE',
credentials: 'same-origin',
headers: {Accept: 'application/json'}
}
)
let r = await resp.json()
if (!resp.ok || r.error) {
toastr.warning(
r.error
? `Failed to delete submission: ${r.error}`
: `Failed to delete submission.`
)
return
}
toastr.success('Submission deleted.')
this.props.onUpdate()
} catch (e) {
console.error(e)
toastr.error(
'Failed to delete submission, see the console for more details.'
)
}
}
}
class FormSettings extends React.Component {
constructor(props) {
super(props)
this.update = this.update.bind(this)
this.deleteForm = this.deleteForm.bind(this)
this.cancelDelete = this.cancelDelete.bind(this)
this.state = {
deleting: false,
temporaryFormChanges: {}
}
}
render() {
let {form} = this.props
let tmp = this.state.temporaryFormChanges
return (
<>
<div className="col-1-1" id="settings">
<FormDescription prefix="Settings for" form={form} />
<div className="container">
<div className="row">
<div className="col-5-6">
<h4>Form Enabled</h4>
<p className="description">
You can disable this form to cause it to stop receiving new
submissions temporarily or permanently.
</p>
</div>
<div className="col-1-6 switch-row">
<label className="switch">
<input
type="checkbox"
onChange={this.update}
checked={'disabled' in tmp ? !tmp.disabled : !form.disabled}
name="disabled"
/>
<span className="slider" />
</label>
</div>
</div>
<div className="row">
<div className="col-5-6">
<div>
<h4>reCAPTCHA</h4>
</div>
<p className="description">
reCAPTCHA provides vital spam protection, but you can turn it
off if you need.
</p>
</div>
<div className="col-1-6 switch-row">
<div>
<label className="switch">
<input
type="checkbox"
onChange={this.update}
checked={
'captcha_disabled' in tmp
? !tmp.captcha_disabled
: !form.captcha_disabled
}
name="captcha_disabled"
/>
<span className="slider" />
</label>
</div>
</div>
</div>
<div className="row">
<div className="col-5-6">
<h4>Email Notifications</h4>
<p className="description">
You can disable the emails Formspree sends if you just want to
download the submissions from the dashboard.
</p>
</div>
<div className="col-1-6 switch-row">
<label className="switch">
<input
type="checkbox"
onChange={this.update}
checked={
'disable_email' in tmp
? !tmp.disable_email
: !form.disable_email
}
name="disable_email"
/>
<span className="slider" />
</label>
</div>
</div>
<div className="row">
<div className="col-5-6">
<h4>Submission Archive</h4>
<p className="description">
You can disable the submission archive if you don't want
Formspree to store your submissions.
</p>
</div>
<div className="col-1-6 switch-row">
<label className="switch">
<input
type="checkbox"
onChange={this.update}
checked={
'disable_storage' in tmp
? !tmp.disable_storage
: !form.disable_storage
}
name="disable_storage"
/>
<span className="slider" />
</label>
</div>
</div>
<div className="row">
<div className={this.state.deleting ? 'col-1-2' : 'col-5-6'}>
<h4>
{this.state.deleting
? 'Are you sure you want to delete?'
: 'Delete Form'}
</h4>
<p className="description">
{this.state.deleting ? (
<span>
This will delete the form on <b>{form.host}</b> targeting{' '}
<b>{form.email}</b> and all its submissions? This action{' '}
<b>cannot</b> be undone.
</span>
) : (
<span>
Deleting the form will erase all traces of this form on
our databases, including all the submissions.
</span>
)}
</p>
</div>
<div
className={
(this.state.deleting ? 'col-1-2' : 'col-1-6') + ' switch-row'
}
>
{this.state.deleting ? (
<>
<button
onClick={this.deleteForm}
className="no-uppercase destructive"
>
Sure, erase everything
</button>
<button
onClick={this.cancelDelete}
className="no-uppercase"
style={{marginRight: '5px'}}
>
No, don't delete!
</button>
</>
) : (
<a onClick={this.deleteForm} href="#">
<i className="fa fa-trash-o delete" />
</a>
)}
</div>
</div>
</div>
</div>
</>
)
}
async update(e) {
let attr = e.currentTarget.name
let val = !e.currentTarget.checked
this.setState(state => {
state.temporaryFormChanges[attr] = val
return state
})
try {
let resp = await fetch(`/api-int/forms/${this.props.form.hashid}`, {
method: 'PATCH',
body: JSON.stringify({
[attr]: val
}),
credentials: 'same-origin',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json'
}
})
let r = await resp.json()
if (!resp.ok || r.error) {
toastr.warning(
r.error
? `Failed to save settings: ${r.error}`
: 'Failed to save settings.'
)
return
}
toastr.success('Settings saved.')
this.props.onUpdate().then(() => {
this.setState({temporaryFormChanges: {}})
})
} catch (e) {
console.error(e)
toastr.error('Failed to update form. See the console for more details.')
this.setState({temporaryFormChanges: {}})
}
}
cancelDelete(e) {
e.preventDefault()
this.setState({deleting: false})
}
async deleteForm(e) {
e.preventDefault()
if (this.props.form.counter > 0 && !this.state.deleting) {
// double-check the user intentions to delete,
// but only if the form has been used already.
this.setState({deleting: true})
return
}
this.setState({deleting: false})
try {
let resp = await fetch(`/api-int/forms/${this.props.form.hashid}`, {
method: 'DELETE',
credentials: 'same-origin',
headers: {
Accept: 'application/json'
}
})
let r = await resp.json()
if (resp.error || r.error) {
toastr.warning(
r.error
? `failed to delete form: ${r.error}`
: 'failed to delete form.'
)
return
}
toastr.success('Form successfully deleted.')
this.props.history.push('/forms')
} catch (e) {
console.error(e)
toastr.error('Failed to delete form. See the console for more details.')
}
}
}
function FormDescription({prefix, form}) {
return (
<h2 className="form-description">
{prefix}{' '}
{!form.hash ? (
<span className="code">/{form.hashid}</span>
) : (
<span className="code">/{form.email}</span>
)}{' '}
{form.host ? (
<>
at <span className="code">{form.host}</span>
{form.sitewide ? ' and all its subpaths.' : null}
</>
) : (
''
)}
{form.hash ? (
<>
<br />
<small>
you can now replace the email in the URL with{' '}
<span className="code">{`/${form.hashid}`}</span>
</small>
</>
) : (
<>
<br />
<small>
targeting <span className="code">{form.email}</span>
</small>
</>
)}
</h2>
)
}

View File

@ -0,0 +1,42 @@
/** @format */
const React = require('react') // eslint-disable-line no-unused-vars
module.exports = FormDescription
function FormDescription({prefix, form}) {
return (
<h2 className="form-description">
{prefix}{' '}
{!form.hash ? (
<span className="code">/{form.hashid}</span>
) : (
<span className="code">/{form.email}</span>
)}{' '}
{form.host ? (
<>
at <span className="code">{form.host}</span>
{form.sitewide ? ' and all its subpaths.' : null}
</>
) : (
''
)}
{form.hash ? (
<>
<br />
<small>
you can now replace the email in the URL with{' '}
<span className="code">{`/${form.hashid}`}</span>
</small>
</>
) : (
<>
<br />
<small>
targeting <span className="code">{form.email}</span>
</small>
</>
)}
</h2>
)
}

View File

@ -0,0 +1,118 @@
/** @format */
const React = require('react')
const CodeMirror = require('react-codemirror2')
const cs = require('class-set')
require('codemirror/mode/xml/xml')
require('codemirror/mode/javascript/javascript')
const {Link} = require('react-router-dom')
module.exports = class FormIntegration extends React.Component {
constructor(props) {
super(props)
this.changeTab = this.changeTab.bind(this)
this.state = {
activeTab: 'HTML',
availableTabs: ['HTML', 'AJAX']
}
}
render() {
let {form} = this.props
var codeSample
var modeSample
switch (this.state.activeTab) {
case 'HTML':
modeSample = 'xml'
codeSample = `<form
action="${form.url}"
method="POST"
>
<label>
Your email:
<input type="text" name="_replyto">
</label>
<label>
Your message:
<textarea name="message"></textarea>
</label>
<!-- your other form fields go here -->
<button type="submit">Send</button>
</form>`
break
case 'AJAX':
modeSample = 'javascript'
codeSample = `// There should be an HTML form elsewhere on the page. See the "HTML" tab.
var form = document.querySelector('form')
var data = new FormData(form)
var req = new XMLHttpRequest()
req.open(form.method, form.action)
req.send(data)`
break
}
var integrationSnippet
if (this.state.activeTab === 'AJAX' && !form.captcha_disabled) {
integrationSnippet = (
<div className="integration-nocode CodeMirror cm-s-oceanic-next">
<p>Want to submit your form through AJAX?</p>
<p>
<Link to={`/forms/${form.hashid}/settings`}>Disable reCAPTCHA</Link>{' '}
for this form to make it possible!
</p>
</div>
)
} else {
integrationSnippet = (
<CodeMirror.UnControlled
value={codeSample}
options={{
theme: 'oceanic-next',
mode: modeSample,
viewportMargin: Infinity
}}
/>
)
}
return (
<>
<div className="col-1-1">
<div className="container">
<div className="integration">
<p>
Paste this code in your HTML, modifying it according to your
needs:
</p>
<div className="integration-tabs">
{this.state.availableTabs.map(tabName => (
<div
key={tabName}
data-tab={tabName}
onClick={this.changeTab}
className={cs({active: this.state.activeTab === tabName})}
>
{tabName}
</div>
))}
</div>
{integrationSnippet}
</div>
</div>
</div>
</>
)
}
changeTab(e) {
e.preventDefault()
this.setState({activeTab: e.target.dataset.tab})
}
}

View File

@ -0,0 +1,269 @@
/** @format */
const toastr = window.toastr
const fetch = window.fetch
const React = require('react')
const FormDescription = require('./FormDescription')
module.exports = class FormSettings extends React.Component {
constructor(props) {
super(props)
this.update = this.update.bind(this)
this.deleteForm = this.deleteForm.bind(this)
this.cancelDelete = this.cancelDelete.bind(this)
this.state = {
deleting: false,
temporaryFormChanges: {}
}
}
render() {
let {form} = this.props
let tmp = this.state.temporaryFormChanges
return (
<>
<div className="col-1-1" id="settings">
<FormDescription prefix="Settings for" form={form} />
<div className="container">
<div className="row">
<div className="col-5-6">
<h4>Form Enabled</h4>
<p className="description">
You can disable this form to cause it to stop receiving new
submissions temporarily or permanently.
</p>
</div>
<div className="col-1-6 switch-row">
<label className="switch">
<input
type="checkbox"
onChange={this.update}
checked={'disabled' in tmp ? !tmp.disabled : !form.disabled}
name="disabled"
/>
<span className="slider" />
</label>
</div>
</div>
<div className="row">
<div className="col-5-6">
<div>
<h4>reCAPTCHA</h4>
</div>
<p className="description">
reCAPTCHA provides vital spam protection, but you can turn it
off if you need.
</p>
</div>
<div className="col-1-6 switch-row">
<div>
<label className="switch">
<input
type="checkbox"
onChange={this.update}
checked={
'captcha_disabled' in tmp
? !tmp.captcha_disabled
: !form.captcha_disabled
}
name="captcha_disabled"
/>
<span className="slider" />
</label>
</div>
</div>
</div>
<div className="row">
<div className="col-5-6">
<h4>Email Notifications</h4>
<p className="description">
You can disable the emails Formspree sends if you just want to
download the submissions from the dashboard.
</p>
</div>
<div className="col-1-6 switch-row">
<label className="switch">
<input
type="checkbox"
onChange={this.update}
checked={
'disable_email' in tmp
? !tmp.disable_email
: !form.disable_email
}
name="disable_email"
/>
<span className="slider" />
</label>
</div>
</div>
<div className="row">
<div className="col-5-6">
<h4>Submission Archive</h4>
<p className="description">
You can disable the submission archive if you don't want
Formspree to store your submissions.
</p>
</div>
<div className="col-1-6 switch-row">
<label className="switch">
<input
type="checkbox"
onChange={this.update}
checked={
'disable_storage' in tmp
? !tmp.disable_storage
: !form.disable_storage
}
name="disable_storage"
/>
<span className="slider" />
</label>
</div>
</div>
<div className="row">
<div className={this.state.deleting ? 'col-1-2' : 'col-5-6'}>
<h4>
{this.state.deleting
? 'Are you sure you want to delete?'
: 'Delete Form'}
</h4>
<p className="description">
{this.state.deleting ? (
<span>
This will delete the form on <b>{form.host}</b> targeting{' '}
<b>{form.email}</b> and all its submissions? This action{' '}
<b>cannot</b> be undone.
</span>
) : (
<span>
Deleting the form will erase all traces of this form on
our databases, including all the submissions.
</span>
)}
</p>
</div>
<div
className={
(this.state.deleting ? 'col-1-2' : 'col-1-6') + ' switch-row'
}
>
{this.state.deleting ? (
<>
<button
onClick={this.deleteForm}
className="no-uppercase destructive"
>
Sure, erase everything
</button>
<button
onClick={this.cancelDelete}
className="no-uppercase"
style={{marginRight: '5px'}}
>
No, don't delete!
</button>
</>
) : (
<a onClick={this.deleteForm} href="#">
<i className="fa fa-trash-o delete" />
</a>
)}
</div>
</div>
</div>
</div>
</>
)
}
async update(e) {
let attr = e.currentTarget.name
let val = !e.currentTarget.checked
this.setState(state => {
state.temporaryFormChanges[attr] = val
return state
})
try {
let resp = await fetch(`/api-int/forms/${this.props.form.hashid}`, {
method: 'PATCH',
body: JSON.stringify({
[attr]: val
}),
credentials: 'same-origin',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json'
}
})
let r = await resp.json()
if (!resp.ok || r.error) {
toastr.warning(
r.error
? `Failed to save settings: ${r.error}`
: 'Failed to save settings.'
)
return
}
toastr.success('Settings saved.')
this.props.onUpdate().then(() => {
this.setState({temporaryFormChanges: {}})
})
} catch (e) {
console.error(e)
toastr.error('Failed to update form. See the console for more details.')
this.setState({temporaryFormChanges: {}})
}
}
cancelDelete(e) {
e.preventDefault()
this.setState({deleting: false})
}
async deleteForm(e) {
e.preventDefault()
if (this.props.form.counter > 0 && !this.state.deleting) {
// double-check the user intentions to delete,
// but only if the form has been used already.
this.setState({deleting: true})
return
}
this.setState({deleting: false})
try {
let resp = await fetch(`/api-int/forms/${this.props.form.hashid}`, {
method: 'DELETE',
credentials: 'same-origin',
headers: {
Accept: 'application/json'
}
})
let r = await resp.json()
if (resp.error || r.error) {
toastr.warning(
r.error
? `failed to delete form: ${r.error}`
: 'failed to delete form.'
)
return
}
toastr.success('Form successfully deleted.')
this.props.history.push('/forms')
} catch (e) {
console.error(e)
toastr.error('Failed to delete form. See the console for more details.')
}
}
}

View File

@ -0,0 +1,150 @@
/** @format */
const toastr = window.toastr
const fetch = window.fetch
const React = require('react')
const FormDescription = require('./FormDescription')
module.exports = class FormSubmissions extends React.Component {
constructor(props) {
super(props)
this.deleteSubmission = this.deleteSubmission.bind(this)
this.showExportButtons = this.showExportButtons.bind(this)
this.state = {
exporting: false
}
}
render() {
let {form} = this.props
return (
<div className="col-1-1 submissions-col">
<FormDescription prefix="Submissions for" form={form} />
{form.submissions.length ? (
<>
<table className="submissions responsive">
<thead>
<tr>
<th>Submitted at</th>
{form.fields
.slice(1 /* the first field is 'date' */)
.map(f => (
<th key={f}>{f}</th>
))}
<th />
</tr>
</thead>
<tbody>
{form.submissions.map(s => (
<tr id={`submission-${s.id}`} key={s.id}>
<td id={`p-${s.id}`} data-label="Submitted at">
{new Date(Date.parse(s.date))
.toString()
.split(' ')
.slice(0, 5)
.join(' ')}
</td>
{form.fields
.slice(1 /* the first field is 'date' */)
.map(f => (
<td data-label={f} key={f}>
<pre>{s[f]}</pre>
</td>
))}
<td>
<button
className="no-border"
data-sub={s.id}
onClick={this.deleteSubmission}
>
<i className="fa fa-trash-o delete" />
</button>
</td>
</tr>
))}
</tbody>
</table>
<div className="container">
<div className="row">
{this.state.exporting ? (
<div className="col-1-1 right">
<a
target="_blank"
className="button"
style={{marginRight: '5px'}}
href={`/forms/${form.hashid}.json`}
>
Export as JSON
</a>
<a
target="_blank"
className="button"
href={`/forms/${form.hashid}.csv`}
>
Export as CSV
</a>
</div>
) : (
<div className="col-1-1 right">
<button
onClick={this.showExportButtons}
href={`/forms/${form.hashid}.json`}
>
Export
</button>
</div>
)}
</div>
</div>
</>
) : (
<h3>No submissions archived yet.</h3>
)}
</div>
)
}
showExportButtons(e) {
e.preventDefault()
this.setState({exporting: true})
}
async deleteSubmission(e) {
e.preventDefault()
let subid = e.currentTarget.dataset.sub
try {
let resp = await fetch(
`/api-int/forms/${this.props.form.hashid}/submissions/${subid}`,
{
method: 'DELETE',
credentials: 'same-origin',
headers: {Accept: 'application/json'}
}
)
let r = await resp.json()
if (!resp.ok || r.error) {
toastr.warning(
r.error
? `Failed to delete submission: ${r.error}`
: `Failed to delete submission.`
)
return
}
toastr.success('Submission deleted.')
this.props.onUpdate()
} catch (e) {
console.error(e)
toastr.error(
'Failed to delete submission, see the console for more details.'
)
}
}
}

View File

@ -0,0 +1,218 @@
/** @format */
const toastr = window.toastr
const fetch = window.fetch
const React = require('react')
const CodeMirror = require('react-codemirror2')
require('codemirror/mode/xml/xml')
require('codemirror/mode/css/css')
const FormDescription = require('./FormDescription')
module.exports = class FormSettings extends React.Component {
constructor(props) {
super(props)
this.changeFrom = this.changeFrom.bind(this)
this.changeSubject = this.changeSubject.bind(this)
this.changeStyle = this.changeStyle.bind(this)
this.changeBody = this.changeBody.bind(this)
this.preview = this.preview.bind(this)
this.closePreview = this.closePreview.bind(this)
this.revert = this.revert.bind(this)
this.deploy = this.deploy.bind(this)
this.defaultValues = {
from: 'Team Formspree',
subject: 'New submission from {{ host }}',
style: `h1 {
color: black;
}
.content {
font-size: 20pt;
}`,
body: `<div class="content">
<h1>You've got a new submissions from {{ _replyto }}</h1>
<table>
{{# _fields }}
<tr>
<th>{{ field_name }}</th>
<td>{{ field_value }}</td>
</tr>
{{/ _fields }}
</table>
</div>`
}
this.state = {
previewing: null,
changes: {}
}
}
render() {
let {form} = this.props
let {from, subject, style, body} = {
...this.defaultValues,
...form.template,
...this.state.changes
}
return (
<>
<div className="col-1-1" id="whitelabel">
<FormDescription prefix="Whitelabel settings for" form={form} />
<div className="container">
<div className="col-1-6">
<label htmlFor="from">From</label>
</div>
<div className="col-5-6">
<input id="from" onChange={this.changeFrom} value={from} />
</div>
</div>
<div className="container">
<div className="col-1-6">
<label htmlFor="subject">Subject</label>
</div>
<div className="col-5-6">
<input
id="subject"
onChange={this.changeSubject}
value={subject}
/>
<div className="right">
Overrides <span className="code">_subject</span> field
</div>
</div>
</div>
<div className="container">
<div className="col-1-1">
<label>
Style
<CodeMirror.Controlled
value={style}
options={{
theme: 'oceanic-next',
mode: 'css',
viewportMargin: Infinity
}}
onBeforeChange={this.changeStyle}
/>
</label>
</div>
</div>
<div className="container">
<div className="col-1-1">
<label>
Body
<CodeMirror.Controlled
value={body}
options={{
theme: 'oceanic-next',
mode: 'xml',
viewportMargin: Infinity
}}
onBeforeChange={this.changeBody}
/>
</label>
</div>
</div>
<div className="container">
<div className="col-1-6">
<button onClick={this.preview}>Preview</button>
</div>
<div className="col-2-6" />
<div className="col-1-6">
{Object.keys(this.state.changes).length > 0
? 'changes pending'
: null}
</div>
<div className="col-1-6">
<button
onClick={this.revert}
disabled={Object.keys(this.state.changes).length > 0}
>
Revert
</button>
</div>
<div className="col-1-6">
<button
onClick={this.deploy}
disabled={Object.keys(this.state.changes).length > 0}
>
Deploy
</button>
</div>
</div>
</div>
</>
)
}
changeFrom(e) {
let value = e.target.value
this.setState(state => {
state.changes.from = value
return state
})
}
changeSubject(e) {
let value = e.target.value
this.setState(state => {
state.changes.subject = value
return state
})
}
changeStyle(_, __, value) {
this.setState(state => {
state.changes.style = value
return state
})
}
changeBody(_, __, value) {
this.setState(state => {
state.changes.body = value
return state
})
}
closePreview() {
this.setState({previewing: null})
}
async preview() {
let template = {
...this.defaultValues,
...this.props.form.template,
...this.state.changes
}
try {
let resp = await fetch('/api-int/forms/whitelabel/preview', {
method: 'POST',
body: JSON.stringify(template),
credentials: 'same-origin',
headers: {
Accept: 'text/html',
'Content-Type': 'application/json'
}
})
let html = await resp.text()
this.setState({previewing: html})
} catch (e) {
console.error(e)
toastr.error(
'Failed to see render preview. See the console for more details.'
)
}
}
revert() {}
deploy() {}
}

View File

@ -0,0 +1,132 @@
/** @format */
const toastr = window.toastr
const fetch = window.fetch
const React = require('react')
const {Route, Link, NavLink, Redirect} = require('react-router-dom')
const Portal = require('../../Portal')
const Integration = require('./Integration')
const Submissions = require('./Submissions')
const Settings = require('./Settings')
const Whitelabel = require('./Whitelabel')
module.exports = class FormPage extends React.Component {
constructor(props) {
super(props)
this.state = {
form: null
}
this.fetchForm = this.fetchForm.bind(this)
}
async componentDidMount() {
this.fetchForm()
}
render() {
let hashid = this.props.match.params.hashid
return (
<>
<Portal to=".menu .item:nth-child(2)">
<Link to="/forms">Your forms</Link>
</Portal>
<Portal to="#header .center">
<h1>Form Details</h1>
</Portal>
<Route
exact
path={`/forms/${hashid}`}
render={() => <Redirect to={`/forms/${hashid}/submissions`} />}
/>
{this.state.form && (
<>
<h4 className="tabs">
<NavLink
to={`/forms/${hashid}/integration`}
activeStyle={{color: 'inherit', cursor: 'normal'}}
>
Integration
</NavLink>
<NavLink
to={`/forms/${hashid}/submissions`}
activeStyle={{color: 'inherit', cursor: 'normal'}}
>
Submissions
</NavLink>
<NavLink
to={`/forms/${hashid}/settings`}
activeStyle={{color: 'inherit', cursor: 'normal'}}
>
Settings
</NavLink>
{this.state.form.features.whitelabel && (
<NavLink
to={`/forms/${hashid}/whitelabel`}
activeStyle={{color: 'inherit', cursor: 'normal'}}
>
Whitelabel
</NavLink>
)}
</h4>
<Route
path="/forms/:hashid/integration"
render={() => (
<Integration form={this.state.form} onUpdate={this.fetchForm} />
)}
/>
<Route
path="/forms/:hashid/submissions"
render={() => (
<Submissions form={this.state.form} onUpdate={this.fetchForm} />
)}
/>
<Route
path="/forms/:hashid/settings"
render={() => (
<Settings
form={this.state.form}
history={this.props.history}
onUpdate={this.fetchForm}
/>
)}
/>
<Route
path="/forms/:hashid/whitelabel"
render={() => (
<Whitelabel form={this.state.form} onUpdate={this.fetchForm} />
)}
/>
</>
)}
</>
)
}
async fetchForm() {
let hashid = this.props.match.params.hashid
try {
let resp = await fetch(`/api-int/forms/${hashid}`, {
credentials: 'same-origin',
headers: {Accept: 'application/json'}
})
let r = await resp.json()
if (!resp.ok || r.error) {
toastr.warning(
r.error ? `Error fetching form: ${r.error}` : 'Error fetching form.'
)
return
}
this.setState({form: r})
} catch (e) {
console.error(e)
toastr.error(`Failed to fetch form, see the console for more details.`)
}
}
}

View File

@ -50,6 +50,7 @@ def configure_routes(app):
app.add_url_rule('/api-int/forms/<hashid>', view_func=fa.delete, methods=['DELETE'])
app.add_url_rule('/api-int/forms/sitewide-check', view_func=fa.sitewide_check, methods=['POST'])
app.add_url_rule('/api-int/forms/<hashid>/submissions/<submissionid>', view_func=fa.submission_delete, methods=['DELETE'])
app.add_url_rule('/api-int/forms/whitelabel/preview', view_func=fa.custom_template_preview_render, methods=['POST'])
# Webhooks
app.add_url_rule('/webhooks/stripe', view_func=uv.stripe_webhook, methods=['POST'])

View File

@ -494,6 +494,18 @@
}
}
#whitelabel {
.container {
display: flex;
align-items: baseline;
}
#from::after {
content: ' submissions@formspree.io';
color: grey;
}
}
a.button.export {
width: 11em;
text-align: center;