basic custom email template editor, model and preview endpoint.
This commit is contained in:
parent
0156b05d4d
commit
0a2f727a3a
1
Pipfile
1
Pipfile
|
@ -34,6 +34,7 @@ unicodecsv = "==0.14.1"
|
||||||
flask-migrate = "==2.2.1"
|
flask-migrate = "==2.2.1"
|
||||||
premailer = "==3.2.0"
|
premailer = "==3.2.0"
|
||||||
ptvsd = "==4.1.2"
|
ptvsd = "==4.1.2"
|
||||||
|
pystache = "*"
|
||||||
|
|
||||||
[pipenv]
|
[pipenv]
|
||||||
allow_prereleases = true
|
allow_prereleases = true
|
||||||
|
|
|
@ -292,6 +292,13 @@
|
||||||
"index": "pypi",
|
"index": "pypi",
|
||||||
"version": "==4.1.2"
|
"version": "==4.1.2"
|
||||||
},
|
},
|
||||||
|
"pystache": {
|
||||||
|
"hashes": [
|
||||||
|
"sha256:f7bbc265fb957b4d6c7c042b336563179444ab313fb93a719759111eabd3b85a"
|
||||||
|
],
|
||||||
|
"index": "pypi",
|
||||||
|
"version": "==0.5.4"
|
||||||
|
},
|
||||||
"python-dateutil": {
|
"python-dateutil": {
|
||||||
"hashes": [
|
"hashes": [
|
||||||
"sha256:1adb80e7a782c12e52ef9a8182bebeb73f1d7e24e374397af06fb4956c8dc5c0",
|
"sha256:1adb80e7a782c12e52ef9a8182bebeb73f1d7e24e374397af06fb4956c8dc5c0",
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
import datetime
|
||||||
|
|
||||||
from urllib.parse import urljoin
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
from flask import request, jsonify, g
|
from flask import request, jsonify, g
|
||||||
|
@ -8,7 +10,7 @@ from formspree.stuff import DB
|
||||||
from formspree.utils import jsonerror, IS_VALID_EMAIL
|
from formspree.utils import jsonerror, IS_VALID_EMAIL
|
||||||
from .helpers import referrer_to_path, sitewide_file_check, remove_www, \
|
from .helpers import referrer_to_path, sitewide_file_check, remove_www, \
|
||||||
referrer_to_baseurl
|
referrer_to_baseurl
|
||||||
from .models import Form, Submission
|
from .models import Form, Submission, EmailTemplate
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
|
@ -104,9 +106,7 @@ def get(hashid):
|
||||||
if not form:
|
if not form:
|
||||||
return jsonerror(404, {'error': "Form not found."})
|
return jsonerror(404, {'error': "Form not found."})
|
||||||
|
|
||||||
for cont in form.controllers:
|
if not form.controlled_by(current_user):
|
||||||
if cont.id == current_user.id: break
|
|
||||||
else:
|
|
||||||
return jsonerror(401, {'error': "You do not control this form."})
|
return jsonerror(401, {'error': "You do not control this form."})
|
||||||
|
|
||||||
submissions, fields = form.submissions_with_fields()
|
submissions, fields = form.submissions_with_fields()
|
||||||
|
@ -193,6 +193,52 @@ def submission_delete(hashid, submissionid):
|
||||||
return jsonify({'ok': True})
|
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
|
@login_required
|
||||||
def sitewide_check():
|
def sitewide_check():
|
||||||
email = request.get_json().get('email')
|
email = request.get_json().get('email')
|
||||||
|
|
|
@ -2,6 +2,7 @@ import hmac
|
||||||
import random
|
import random
|
||||||
import hashlib
|
import hashlib
|
||||||
import datetime
|
import datetime
|
||||||
|
import pystache
|
||||||
|
|
||||||
from flask import url_for, render_template, render_template_string, g
|
from flask import url_for, render_template, render_template_string, g
|
||||||
from sqlalchemy.sql import table
|
from sqlalchemy.sql import table
|
||||||
|
@ -11,6 +12,7 @@ from sqlalchemy.ext.mutable import MutableDict
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
from werkzeug.datastructures import ImmutableMultiDict, \
|
from werkzeug.datastructures import ImmutableMultiDict, \
|
||||||
ImmutableOrderedMultiDict
|
ImmutableOrderedMultiDict
|
||||||
|
from premailer import transform
|
||||||
|
|
||||||
from formspree import settings
|
from formspree import settings
|
||||||
from formspree.stuff import DB, redis_store, TEMPLATES
|
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'
|
owner = DB.relationship('User') # direct owner, defined by 'owner_id'
|
||||||
# this property is basically useless. use .controllers
|
# this property is basically useless. use .controllers
|
||||||
|
template = DB.relationship('EmailTemplate', uselist=False, back_populates='form')
|
||||||
submissions = DB.relationship('Submission',
|
submissions = DB.relationship('Submission',
|
||||||
backref='form', lazy='dynamic', order_by=lambda: Submission.id.desc())
|
backref='form', lazy='dynamic', order_by=lambda: Submission.id.desc())
|
||||||
|
|
||||||
|
@ -106,6 +109,16 @@ class Form(DB.Model):
|
||||||
.filter(Form.id == self.id)
|
.filter(Form.id == self.id)
|
||||||
return by_email.union(by_creation)
|
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):
|
def has_feature(self, feature):
|
||||||
c = [user for user in self.controllers if user.has_feature(feature)]
|
c = [user for user in self.controllers if user.has_feature(feature)]
|
||||||
return len(c) > 0
|
return len(c) > 0
|
||||||
|
@ -126,6 +139,8 @@ class Form(DB.Model):
|
||||||
'counter': self.counter,
|
'counter': self.counter,
|
||||||
'email': self.email,
|
'email': self.email,
|
||||||
'host': self.host,
|
'host': self.host,
|
||||||
|
'template': self.template,
|
||||||
|
'features': {f: True for f in self.features},
|
||||||
'confirm_sent': self.confirm_sent,
|
'confirm_sent': self.confirm_sent,
|
||||||
'confirmed': self.confirmed,
|
'confirmed': self.confirmed,
|
||||||
'disabled': self.disabled,
|
'disabled': self.disabled,
|
||||||
|
@ -274,6 +289,13 @@ class Form(DB.Model):
|
||||||
text = render_template('email/form.txt',
|
text = render_template('email/form.txt',
|
||||||
data=data, host=self.host, keys=keys, now=now,
|
data=data, host=self.host, keys=keys, now=now,
|
||||||
unconfirm_url=unconfirm)
|
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
|
# check if the user wants a new or old version of the email
|
||||||
if format == 'plain':
|
if format == 'plain':
|
||||||
html = render_template('email/plain_form.html',
|
html = render_template('email/plain_form.html',
|
||||||
|
@ -486,6 +508,52 @@ class Form(DB.Model):
|
||||||
return True
|
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):
|
class Submission(DB.Model):
|
||||||
__tablename__ = 'submissions'
|
__tablename__ = 'submissions'
|
||||||
|
|
||||||
|
|
|
@ -496,9 +496,7 @@ def export_submissions(hashid, format=None):
|
||||||
return jsonerror(402, {'error': "Please upgrade your account."})
|
return jsonerror(402, {'error': "Please upgrade your account."})
|
||||||
|
|
||||||
form = Form.get_with_hashid(hashid)
|
form = Form.get_with_hashid(hashid)
|
||||||
for cont in form.controllers:
|
if not form.controlled_by(current_user):
|
||||||
if cont.id == current_user.id: break
|
|
||||||
else:
|
|
||||||
return abort(401)
|
return abort(401)
|
||||||
|
|
||||||
submissions, fields = form.submissions_with_fields()
|
submissions, fields = form.submissions_with_fields()
|
||||||
|
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -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>
|
||||||
|
)
|
||||||
|
}
|
|
@ -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})
|
||||||
|
}
|
||||||
|
}
|
|
@ -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.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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() {}
|
||||||
|
}
|
|
@ -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.`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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/<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/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/<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
|
# Webhooks
|
||||||
app.add_url_rule('/webhooks/stripe', view_func=uv.stripe_webhook, methods=['POST'])
|
app.add_url_rule('/webhooks/stripe', view_func=uv.stripe_webhook, methods=['POST'])
|
||||||
|
|
|
@ -494,6 +494,18 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#whitelabel {
|
||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
#from::after {
|
||||||
|
content: ' submissions@formspree.io';
|
||||||
|
color: grey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
a.button.export {
|
a.button.export {
|
||||||
width: 11em;
|
width: 11em;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
Loading…
Reference in New Issue