Merge pull request #201 from formspree/whitelabel-custom-email
Whitelabel custom email
This commit is contained in:
commit
e45370ebc9
3
Pipfile
3
Pipfile
|
@ -19,9 +19,9 @@ hashids = "==1.2.0"
|
|||
psycopg2 = "==2.7.4"
|
||||
redis = "==2.10.5"
|
||||
requests = "==2.1.0"
|
||||
SQLAlchemy = "==1.0.13"
|
||||
stripe = "==1.55.0"
|
||||
structlog = "==16.1.0"
|
||||
SQLAlchemy = "==1.0.13"
|
||||
Flask = "==1"
|
||||
Flask-CDN = "==1.5.3"
|
||||
Flask-Cors = "==3.0.2"
|
||||
|
@ -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
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"_meta": {
|
||||
"hash": {
|
||||
"sha256": "0beec9c65d249913687cbf70f560a64c506f6f3bfa339c178bd90c4d30efb986"
|
||||
"sha256": "bfe328625ab39aac4b6c31f49f6cd5b34e4b880dc924e2f85e729d5f1a8a3368"
|
||||
},
|
||||
"pipfile-spec": 6,
|
||||
"requires": {
|
||||
|
@ -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",
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import datetime
|
||||
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from flask import request, jsonify, g
|
||||
|
@ -8,13 +10,13 @@ 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
|
||||
def list():
|
||||
# grab all the forms this user controls
|
||||
if current_user.upgraded:
|
||||
if current_user.has_feature('dashboard'):
|
||||
forms = current_user.forms.order_by(Form.id.desc()).all()
|
||||
else:
|
||||
forms = []
|
||||
|
@ -22,8 +24,8 @@ def list():
|
|||
return jsonify({
|
||||
'ok': True,
|
||||
'user': {
|
||||
'upgraded': current_user.upgraded,
|
||||
'email': current_user.email
|
||||
'features': {f: True for f in current_user.features},
|
||||
'email': current_user.email
|
||||
},
|
||||
'forms': [f.serialize() for f in forms]
|
||||
})
|
||||
|
@ -37,8 +39,8 @@ def create():
|
|||
if referrer != service:
|
||||
return jsonerror(400, {'error': 'Improper request.'})
|
||||
|
||||
if not current_user.upgraded:
|
||||
g.log.info('Failed to create form from dashboard. User is not upgraded.')
|
||||
if not current_user.has_feature('dashboard'):
|
||||
g.log.info('Failed to create form from dashboard. Forbidden.')
|
||||
return jsonerror(402, {'error': "Please upgrade your account."})
|
||||
|
||||
email = request.get_json().get('email')
|
||||
|
@ -97,16 +99,14 @@ def create():
|
|||
|
||||
@login_required
|
||||
def get(hashid):
|
||||
if not current_user.upgraded:
|
||||
if not current_user.has_feature('dashboard'):
|
||||
return jsonerror(402, {'error': "Please upgrade your account."})
|
||||
|
||||
form = Form.get_with_hashid(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,35 @@ 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."})
|
||||
|
||||
template = form.template
|
||||
if not template:
|
||||
template = EmailTemplate(form_id=form.id)
|
||||
|
||||
template.from_name = request.get_json()['from_name']
|
||||
template.subject = request.get_json()['subject']
|
||||
template.style = request.get_json()['style']
|
||||
template.body = request.get_json()['body']
|
||||
|
||||
try:
|
||||
template.sample()
|
||||
except Exception as e:
|
||||
print(e)
|
||||
return jsonerror(406, {'error': "Failed to render. The template has errors."})
|
||||
|
||||
DB.session.add(template)
|
||||
DB.session.commit()
|
||||
return jsonify({'ok': True})
|
||||
|
||||
|
||||
@login_required
|
||||
def sitewide_check():
|
||||
email = request.get_json().get('email')
|
||||
|
|
|
@ -2,18 +2,23 @@ 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
|
||||
from sqlalchemy.sql.expression import delete
|
||||
from sqlalchemy.dialects.postgresql import JSON
|
||||
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
|
||||
from formspree.utils import send_email, unix_time_for_12_months_from_now, \
|
||||
next_url, IS_VALID_EMAIL, request_wants_json
|
||||
from formspree.users.models import Plan
|
||||
from .helpers import HASH, HASHIDS_CODEC, REDIS_COUNTER_KEY, \
|
||||
http_form_to_dict, referrer_to_path, \
|
||||
store_first_submission, fetch_first_submission, \
|
||||
|
@ -40,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())
|
||||
|
||||
|
@ -104,9 +110,18 @@ class Form(DB.Model):
|
|||
return by_email.union(by_creation)
|
||||
|
||||
@property
|
||||
def upgraded(self):
|
||||
upgraded_controllers = [i for i in self.controllers if i.upgraded]
|
||||
return len(upgraded_controllers) > 0
|
||||
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
|
||||
|
||||
@classmethod
|
||||
def get_with_hashid(cls, hashid):
|
||||
|
@ -124,6 +139,8 @@ class Form(DB.Model):
|
|||
'counter': self.counter,
|
||||
'email': self.email,
|
||||
'host': self.host,
|
||||
'template': self.template.serialize() if self.template else None,
|
||||
'features': {f: True for f in self.features},
|
||||
'confirm_sent': self.confirm_sent,
|
||||
'confirmed': self.confirmed,
|
||||
'disabled': self.disabled,
|
||||
|
@ -176,6 +193,7 @@ class Form(DB.Model):
|
|||
next = next_url(referrer, data.get('_next'))
|
||||
spam = data.get('_gotcha', None)
|
||||
format = data.get('_format', None)
|
||||
from_name = None
|
||||
|
||||
# turn cc emails into array
|
||||
if cc:
|
||||
|
@ -207,8 +225,8 @@ class Form(DB.Model):
|
|||
# increment the forms counter
|
||||
self.counter = Form.counter + 1
|
||||
|
||||
# if submission storage is disabled and form is upgraded, don't store submission
|
||||
if self.disable_storage and self.upgraded:
|
||||
# if submission storage is disabled, don't store submission
|
||||
if self.disable_storage and self.has_feature('dashboard'):
|
||||
pass
|
||||
else:
|
||||
DB.session.add(self)
|
||||
|
@ -239,17 +257,18 @@ class Form(DB.Model):
|
|||
# url to request_unconfirm_form page
|
||||
unconfirm = url_for('request_unconfirm_form', form_id=self.id, _external=True)
|
||||
|
||||
# check if the forms are over the counter and the user is not upgraded
|
||||
# check if the forms are over the counter and the user has unlimited submissions
|
||||
overlimit = False
|
||||
monthly_counter = self.get_monthly_counter()
|
||||
monthly_limit = settings.MONTHLY_SUBMISSIONS_LIMIT \
|
||||
if self.id > settings.FORM_LIMIT_DECREASE_ACTIVATION_SEQUENCE \
|
||||
else settings.GRANDFATHER_MONTHLY_LIMIT
|
||||
|
||||
if monthly_counter > monthly_limit and not self.upgraded:
|
||||
if monthly_counter > monthly_limit and not self.has_feature('unlimited'):
|
||||
overlimit = True
|
||||
|
||||
if monthly_counter == int(monthly_limit * 0.9) and not self.upgraded:
|
||||
if monthly_counter == int(monthly_limit * 0.9) and \
|
||||
not self.has_feature('unlimited'):
|
||||
# send email notification
|
||||
send_email(
|
||||
to=self.email,
|
||||
|
@ -271,8 +290,15 @@ class Form(DB.Model):
|
|||
text = render_template('email/form.txt',
|
||||
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':
|
||||
|
||||
# if there's a custom email template we should use it
|
||||
# otherwise check if the user wants a new or old version of the email
|
||||
if self.owner and self.owner.has_feature('whitelabel') and self.template:
|
||||
html, subject = self.template.render_body_and_subject(
|
||||
data=data, host=self.host, keys=keys, now=now,
|
||||
unconfirm_url=unconfirm)
|
||||
from_name = self.template.from_name
|
||||
elif format == 'plain':
|
||||
html = render_template('email/plain_form.html',
|
||||
data=data, host=self.host, keys=keys, now=now,
|
||||
unconfirm_url=unconfirm)
|
||||
|
@ -295,8 +321,8 @@ class Form(DB.Model):
|
|||
else:
|
||||
return {'code': Form.STATUS_OVERLIMIT}
|
||||
|
||||
# if emails are disabled and form is upgraded, don't send email notification
|
||||
if self.disable_email and self.upgraded:
|
||||
# if emails are disabled, don't send email notification
|
||||
if self.disable_email and self.has_feature('dashboard'):
|
||||
return {'code': Form.STATUS_NO_EMAIL, 'next': next}
|
||||
else:
|
||||
result = send_email(
|
||||
|
@ -305,6 +331,7 @@ class Form(DB.Model):
|
|||
text=text,
|
||||
html=html,
|
||||
sender=settings.DEFAULT_SENDER,
|
||||
from_name=from_name,
|
||||
reply_to=reply_to,
|
||||
cc=cc,
|
||||
headers={
|
||||
|
@ -483,15 +510,81 @@ class Form(DB.Model):
|
|||
return True
|
||||
|
||||
|
||||
from sqlalchemy.dialects.postgresql import JSON
|
||||
from sqlalchemy.ext.mutable import MutableDict
|
||||
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 make_sample(cls, style, body,
|
||||
from_name='Formspree Team',
|
||||
subject='New submission from {{ _host }}'):
|
||||
t = cls(0)
|
||||
t.from_name = from_name
|
||||
t.subject = subject
|
||||
t.style = style
|
||||
t.body = body
|
||||
return t.sample()
|
||||
|
||||
def sample(self):
|
||||
return self.render_body_and_subject(
|
||||
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='#'
|
||||
)
|
||||
|
||||
def serialize(self):
|
||||
return {
|
||||
'subject': self.subject,
|
||||
'from_name': self.from_name,
|
||||
'style': self.style,
|
||||
'body': self.body
|
||||
}
|
||||
|
||||
def render_body_and_subject(self, data, host, keys, now, unconfirm_url):
|
||||
data.update({
|
||||
'_fields': [{'_name': f, '_value': data[f]} for f in keys],
|
||||
'_time': now,
|
||||
'_host': host
|
||||
})
|
||||
subject = pystache.render(self.subject, data)
|
||||
html = pystache.render('<style>' + self.style + '</style>' + self.body, data)
|
||||
print(html)
|
||||
inlined = transform(html)
|
||||
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, subject
|
||||
|
||||
|
||||
class Submission(DB.Model):
|
||||
__tablename__ = 'submissions'
|
||||
|
||||
id = DB.Column(DB.Integer, primary_key=True)
|
||||
submitted_at = DB.Column(DB.DateTime)
|
||||
form_id = DB.Column(DB.Integer, DB.ForeignKey('forms.id'))
|
||||
form_id = DB.Column(DB.Integer, DB.ForeignKey('forms.id'), nullable=False)
|
||||
data = DB.Column(MutableDict.as_mutable(JSON))
|
||||
|
||||
def __init__(self, form_id):
|
||||
|
|
|
@ -4,6 +4,7 @@ import requests
|
|||
import datetime
|
||||
import io
|
||||
from urllib.parse import urljoin
|
||||
from lxml.html import rewrite_links
|
||||
|
||||
from flask import request, url_for, render_template, redirect, \
|
||||
jsonify, flash, make_response, Response, g, \
|
||||
|
@ -20,7 +21,7 @@ from .helpers import http_form_to_dict, ordered_storage, referrer_to_path, \
|
|||
remove_www, referrer_to_baseurl, sitewide_file_check, \
|
||||
verify_captcha, temp_store_hostname, get_temp_hostname, \
|
||||
HASH, assign_ajax, KEYS_EXCLUDED_FROM_EMAIL
|
||||
from .models import Form, Submission
|
||||
from .models import Form, Submission, EmailTemplate
|
||||
|
||||
|
||||
def thanks():
|
||||
|
@ -195,8 +196,8 @@ def send(email_or_string):
|
|||
captcha_verified or
|
||||
settings.TESTING)
|
||||
|
||||
# if form is upgraded check if captcha is disabled
|
||||
if form.upgraded:
|
||||
# check if captcha is disabled
|
||||
if form.has_feature('dashboard'):
|
||||
needs_captcha = needs_captcha and not form.captcha_disabled
|
||||
|
||||
if needs_captcha:
|
||||
|
@ -490,15 +491,25 @@ def serve_dashboard(hashid=None, s=None):
|
|||
return render_template('forms/dashboard.html')
|
||||
|
||||
|
||||
@login_required
|
||||
def custom_template_preview_render():
|
||||
body, _ = EmailTemplate.make_sample(
|
||||
from_name=request.args.get('from_name'),
|
||||
subject=request.args.get('subject'),
|
||||
style=request.args.get('style'),
|
||||
body=request.args.get('body'),
|
||||
)
|
||||
|
||||
return rewrite_links(body, lambda x: "#" + x)
|
||||
|
||||
|
||||
@login_required
|
||||
def export_submissions(hashid, format=None):
|
||||
if not current_user.upgraded:
|
||||
if not current_user.has_feature('dashboard'):
|
||||
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()
|
||||
|
|
|
@ -36,18 +36,6 @@ module.exports = class CreateForm extends React.Component {
|
|||
}
|
||||
|
||||
render() {
|
||||
if (!this.props.user.upgraded) {
|
||||
return (
|
||||
<div className="col-1-1 create-form">
|
||||
<h6 className="light">
|
||||
Please <a href="/account">upgrade your account</a> in order to
|
||||
create forms from the dashboard and manage the forms currently
|
||||
associated with your emails.
|
||||
</h6>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
let {
|
||||
email,
|
||||
url: urlv,
|
||||
|
|
|
@ -50,6 +50,8 @@ module.exports = class FormList extends React.Component {
|
|||
}
|
||||
|
||||
render() {
|
||||
let {user, enabled_forms, disabled_forms} = this.state
|
||||
|
||||
if (this.state.loading) {
|
||||
return (
|
||||
<div className="col-1-1">
|
||||
|
@ -69,6 +71,18 @@ module.exports = class FormList extends React.Component {
|
|||
)
|
||||
}
|
||||
|
||||
if (!user.features.dashboard) {
|
||||
return (
|
||||
<div className="col-1-1">
|
||||
<p>
|
||||
Please <a href="/account">upgrade your account</a> in order to
|
||||
create forms from the dashboard and manage the forms currently
|
||||
associated with your emails.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Portal to=".menu .item:nth-child(2)">
|
||||
|
@ -79,10 +93,10 @@ module.exports = class FormList extends React.Component {
|
|||
</Portal>
|
||||
<div className="col-1-1">
|
||||
<h4>Active Forms</h4>
|
||||
{this.state.enabled_forms.length ? (
|
||||
{enabled_forms.length ? (
|
||||
<table className="forms responsive">
|
||||
<tbody>
|
||||
{this.state.enabled_forms.map(form => (
|
||||
{enabled_forms.map(form => (
|
||||
<FormItem key={form.hashid} {...form} />
|
||||
))}
|
||||
</tbody>
|
||||
|
@ -94,12 +108,12 @@ module.exports = class FormList extends React.Component {
|
|||
</p>
|
||||
)}
|
||||
|
||||
{this.state.disabled_forms.length ? (
|
||||
{disabled_forms.length ? (
|
||||
<>
|
||||
<h4>Disabled Forms</h4>
|
||||
<table className="forms responsive">
|
||||
<tbody>
|
||||
{this.state.disabled_forms.map(form => (
|
||||
{disabled_forms.map(form => (
|
||||
<FormItem key={form.hashid} {...form} />
|
||||
))}
|
||||
</tbody>
|
||||
|
@ -107,9 +121,9 @@ module.exports = class FormList extends React.Component {
|
|||
</>
|
||||
) : null}
|
||||
|
||||
{this.state.enabled_forms.length === 0 &&
|
||||
this.state.disabled_forms.length === 0 &&
|
||||
this.state.user.upgraded ? (
|
||||
{enabled_forms.length === 0 &&
|
||||
disabled_forms.length === 0 &&
|
||||
user.features.dashboard ? (
|
||||
<h6 className="light">
|
||||
You don't have any forms associated with this account, maybe you
|
||||
should <a href="/account">verify your email</a>.
|
||||
|
@ -117,7 +131,7 @@ module.exports = class FormList extends React.Component {
|
|||
) : null}
|
||||
</div>
|
||||
|
||||
<CreateForm user={this.state.user} {...this.props} />
|
||||
<CreateForm user={user} {...this.props} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
@ -200,6 +214,30 @@ class FormItem extends React.Component {
|
|||
</Link>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{form.features.whitelabel && (
|
||||
<Link
|
||||
to={`/forms/${form.hashid}/whitelabel`}
|
||||
className="no-underline"
|
||||
>
|
||||
{form.template ? (
|
||||
<span
|
||||
className="tooltip hint--top"
|
||||
data-hint="Uses a custom email template."
|
||||
>
|
||||
<span className="ion-document-text" />
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className="tooltip hint--top"
|
||||
data-hint="Doesn't use a custom email template."
|
||||
>
|
||||
<span className="ion-document" />
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<Link to={`/forms/${form.hashid}/settings`} className="no-underline">
|
||||
<span className="ion-gear-b" />
|
||||
|
|
|
@ -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,18 @@
|
|||
/** @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>
|
||||
)}
|
||||
</h2>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,119 @@
|
|||
/** @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.availableTabs = ['HTML', 'AJAX']
|
||||
|
||||
this.state = {
|
||||
activeTab: 'HTML'
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
<p>
|
||||
Paste this code in your HTML, modifying it according to your
|
||||
needs:
|
||||
</p>
|
||||
<div className="code-tabs">
|
||||
{this.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,219 @@
|
|||
/** @format */
|
||||
|
||||
const toastr = window.toastr
|
||||
const fetch = window.fetch
|
||||
const React = require('react')
|
||||
|
||||
const SettingsSwitch = require('./SettingsSwitch')
|
||||
|
||||
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="container" id="settings">
|
||||
<SettingsSwitch
|
||||
title="Form Enabled"
|
||||
fieldName="disabled"
|
||||
description="You can disable this form to cause it to stop receiving new
|
||||
submissions temporarily or permanently."
|
||||
onChangeFn={() => this.update}
|
||||
checkedFn={() =>
|
||||
'disabled' in tmp
|
||||
? !tmp.disabled
|
||||
: !form.disabled
|
||||
}
|
||||
></SettingsSwitch>
|
||||
<SettingsSwitch
|
||||
title="reCAPTCHA"
|
||||
fieldName="captcha_disabled"
|
||||
description="reCAPTCHA provides vital spam protection, but you can turn it
|
||||
off if you need."
|
||||
onChangeFn={() => this.update}
|
||||
checkedFn={() =>
|
||||
'captcha_disabled' in tmp
|
||||
? !tmp.captcha_disabled
|
||||
: !form.captcha_disabled
|
||||
}
|
||||
></SettingsSwitch>
|
||||
<SettingsSwitch
|
||||
title="Email Notifications"
|
||||
fieldName="disable_email"
|
||||
description="You can disable the emails Formspree sends if you just want to
|
||||
download the submissions from the dashboard."
|
||||
onChangeFn={() => this.update}
|
||||
checkedFn={() =>
|
||||
'disable_email' in tmp
|
||||
? !tmp.disable_email
|
||||
: !form.disable_email
|
||||
}
|
||||
></SettingsSwitch>
|
||||
<SettingsSwitch
|
||||
title="Submission Archive"
|
||||
fieldName="disable_storage"
|
||||
description="You can disable the submission archive if you don't want
|
||||
Formspree to store your submissions."
|
||||
onChangeFn={() => this.update}
|
||||
checkedFn={() =>
|
||||
'disable_storage' in tmp
|
||||
? !tmp.disable_storage
|
||||
: !form.disable_storage
|
||||
}
|
||||
></SettingsSwitch>
|
||||
|
||||
<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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
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,35 @@
|
|||
/** @format */
|
||||
|
||||
const React = require('react') // eslint-disable-line no-unused-vars
|
||||
|
||||
module.exports = SettingsSwitch
|
||||
|
||||
function SettingsSwitch({title, fieldName, description, checkedFn, onChangeFn}) {
|
||||
return (
|
||||
<>
|
||||
<div className="row">
|
||||
<div className="col-1-1">
|
||||
<h4>{title}</h4>
|
||||
</div>
|
||||
<div className="switch-row">
|
||||
<label className="switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
onChange={onChangeFn()}
|
||||
checked={checkedFn()}
|
||||
name={fieldName}
|
||||
/>
|
||||
<span className="slider" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="row">
|
||||
<div className="col-1-1">
|
||||
<p className="description">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,147 @@
|
|||
/** @format */
|
||||
|
||||
const toastr = window.toastr
|
||||
const fetch = window.fetch
|
||||
const React = require('react')
|
||||
|
||||
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">
|
||||
{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,389 @@
|
|||
/** @format */
|
||||
|
||||
const toastr = window.toastr
|
||||
const fetch = window.fetch
|
||||
const cs = require('class-set')
|
||||
const qs = require('query-string')
|
||||
const React = require('react')
|
||||
const CodeMirror = require('react-codemirror2')
|
||||
const Modal = require('react-modal')
|
||||
require('codemirror/mode/xml/xml')
|
||||
require('codemirror/mode/css/css')
|
||||
|
||||
const MODAL_REVERT = 'revert'
|
||||
const MODAL_PREVIEW = 'preview'
|
||||
const MODAL_SYNTAX = 'syntax'
|
||||
|
||||
module.exports = class FormSettings extends React.Component {
|
||||
constructor(props) {
|
||||
super(props)
|
||||
|
||||
this.changeFromName = this.changeFromName.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.showSyntax = this.showSyntax.bind(this)
|
||||
this.closeModal = this.closeModal.bind(this)
|
||||
this.changeTab = this.changeTab.bind(this)
|
||||
this.attemptRevert = this.attemptRevert.bind(this)
|
||||
this.revert = this.revert.bind(this)
|
||||
this.deploy = this.deploy.bind(this)
|
||||
|
||||
this.defaultValues = {
|
||||
from_name: 'Team Formspree',
|
||||
subject: 'New submission from {{ _host }}',
|
||||
style: `h1 {
|
||||
color: black;
|
||||
}
|
||||
.content {
|
||||
font-size: 15pt;
|
||||
}`,
|
||||
body: `<div class="content">
|
||||
<h1>You've got a new submission from {{ _replyto }} on {{ _host }}</h1>
|
||||
|
||||
<table>
|
||||
{{# _fields }}
|
||||
<tr>
|
||||
<th>{{ _name }}</th>
|
||||
<td>{{ _value }}</td>
|
||||
</tr>
|
||||
{{/ _fields }}
|
||||
</table>
|
||||
</div>
|
||||
<p>This submission was sent on {{ _time }}.</p>
|
||||
<br>
|
||||
<hr>`
|
||||
}
|
||||
|
||||
this.availableTabs = ['HTML', 'CSS']
|
||||
|
||||
this.state = {
|
||||
changes: {},
|
||||
modal: null,
|
||||
activeTab: 'HTML'
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
let {form} = this.props
|
||||
|
||||
let {from_name, subject, style, body} = {
|
||||
...this.defaultValues,
|
||||
...form.template,
|
||||
...this.state.changes
|
||||
}
|
||||
|
||||
var shownCode
|
||||
switch (this.state.activeTab) {
|
||||
case 'CSS':
|
||||
shownCode = (
|
||||
<CodeMirror.Controlled
|
||||
value={style}
|
||||
options={{
|
||||
theme: 'oceanic-next',
|
||||
mode: 'css',
|
||||
viewportMargin: Infinity
|
||||
}}
|
||||
onBeforeChange={this.changeStyle}
|
||||
/>
|
||||
)
|
||||
break
|
||||
case 'HTML':
|
||||
shownCode = (
|
||||
<CodeMirror.Controlled
|
||||
value={body}
|
||||
options={{
|
||||
theme: 'oceanic-next',
|
||||
mode: 'xml',
|
||||
viewportMargin: Infinity
|
||||
}}
|
||||
onBeforeChange={this.changeBody}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div id="whitelabel">
|
||||
<div className="container">
|
||||
<form>
|
||||
<div className="col-1-6">
|
||||
<label htmlFor="from_name">From</label>
|
||||
</div>
|
||||
<div className="col-5-6">
|
||||
<input
|
||||
id="from_name"
|
||||
onChange={this.changeFromName}
|
||||
value={from_name}
|
||||
/>
|
||||
</div>
|
||||
<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="subtext">
|
||||
Overrides <span className="code">_subject</span> field
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div className="container">
|
||||
<div className="col-1-1 right">
|
||||
<div className="syntax_ref">
|
||||
<a href="#" onClick={this.showSyntax}>
|
||||
syntax quick reference
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-1-1">
|
||||
<div className="code-tabs">
|
||||
{this.availableTabs.map(tabName => (
|
||||
<div
|
||||
key={tabName}
|
||||
data-tab={tabName}
|
||||
onClick={this.changeTab}
|
||||
className={cs({active: this.state.activeTab === tabName})}
|
||||
>
|
||||
{tabName}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{shownCode}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="container">
|
||||
<div className="col-1-3">
|
||||
<button onClick={this.preview}>Preview</button>
|
||||
</div>
|
||||
<div className="col-1-3 right">
|
||||
{Object.keys(this.state.changes).length > 0
|
||||
? 'changes pending'
|
||||
: '\u00A0'}
|
||||
</div>
|
||||
<div className="col-1-6 right">
|
||||
<button
|
||||
onClick={this.attemptRevert}
|
||||
disabled={Object.keys(this.state.changes).length === 0}
|
||||
>
|
||||
Revert
|
||||
</button>
|
||||
</div>
|
||||
<div className="col-1-6 right">
|
||||
<button
|
||||
onClick={this.deploy}
|
||||
disabled={Object.keys(this.state.changes).length === 0}
|
||||
>
|
||||
Deploy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Modal
|
||||
contentLabel="Revert changes"
|
||||
isOpen={this.state.modal === MODAL_REVERT}
|
||||
onRequestClose={this.closeModal}
|
||||
className="dummy"
|
||||
overlayClassName="dummy"
|
||||
>
|
||||
<div>
|
||||
<div className="container">
|
||||
<h2>Are you sure?</h2>
|
||||
<p>
|
||||
Reverting will discard the changes you've made to your email
|
||||
template.
|
||||
</p>
|
||||
</div>
|
||||
<div className="container right">
|
||||
<button onClick={this.closeModal}>Cancel</button>
|
||||
<button onClick={this.revert}>Revert</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
<Modal
|
||||
contentLabel="Preview"
|
||||
isOpen={this.state.modal === MODAL_PREVIEW}
|
||||
onRequestClose={this.closeModal}
|
||||
className="dummy"
|
||||
overlayClassName="dummy"
|
||||
>
|
||||
<div id="whitelabel-preview-modal">
|
||||
<iframe
|
||||
className="preview"
|
||||
src={
|
||||
'/forms/whitelabel/preview?' +
|
||||
qs.stringify({
|
||||
from_name,
|
||||
subject,
|
||||
style,
|
||||
body
|
||||
})
|
||||
}
|
||||
/>
|
||||
<div className="container right">
|
||||
<button onClick={this.closeModal}>OK</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
<Modal
|
||||
contentLabel="Email Syntax"
|
||||
isOpen={this.state.modal === MODAL_SYNTAX}
|
||||
onRequestClose={this.closeModal}
|
||||
className="dummy"
|
||||
overlayClassName="dummy"
|
||||
>
|
||||
<div>
|
||||
<div>
|
||||
<h2>Email Syntax</h2>
|
||||
<p>
|
||||
the email body can contain simple HTML that's valid in an email.
|
||||
No <span className="code"><script></span> or{' '}
|
||||
<span className="code"><style></span> tags can be
|
||||
included. For a list of recommended HTML tags see{' '}
|
||||
<a href="" target="_blank">
|
||||
the ContantContact guide to HTML in email
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
<p>
|
||||
The following special variables are recognized by Formspree,
|
||||
using the{' '}
|
||||
<a
|
||||
href="https://mustache.github.io/mustache.5.html"
|
||||
target="_blank"
|
||||
>
|
||||
mustache
|
||||
</a>{' '}
|
||||
template language.
|
||||
</p>
|
||||
<pre>
|
||||
{`
|
||||
{{ _time }} The date and time of the submission.
|
||||
{{ _host }} The URL of the form (without "https://").
|
||||
{{ <fieldname> }} Any named input value in your form.
|
||||
{{# _fields }} Starts a list of all fields.
|
||||
{{ _name }} Within _fields, the current field name…
|
||||
{{ _value }} … and field value.
|
||||
{{/ _fields }} Closes the _fields block.
|
||||
`.trim()}
|
||||
</pre>
|
||||
</div>
|
||||
<div className="container right">
|
||||
<button onClick={this.closeModal}>OK</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
changeTab(e) {
|
||||
e.preventDefault()
|
||||
this.setState({activeTab: e.target.dataset.tab})
|
||||
}
|
||||
|
||||
changeFromName(e) {
|
||||
let value = e.target.value
|
||||
this.setState(state => {
|
||||
state.changes.from_name = 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
|
||||
})
|
||||
}
|
||||
|
||||
closeModal() {
|
||||
this.setState({modal: null})
|
||||
}
|
||||
|
||||
async preview(e) {
|
||||
e.preventDefault()
|
||||
this.setState({modal: MODAL_PREVIEW})
|
||||
}
|
||||
|
||||
attemptRevert(e) {
|
||||
e.preventDefault()
|
||||
|
||||
this.setState({modal: MODAL_REVERT})
|
||||
}
|
||||
|
||||
revert(e) {
|
||||
e.preventDefault()
|
||||
|
||||
this.setState({changes: {}, modal: null})
|
||||
}
|
||||
|
||||
showSyntax(e) {
|
||||
e.preventDefault()
|
||||
this.setState({modal: MODAL_SYNTAX})
|
||||
}
|
||||
|
||||
async deploy(e) {
|
||||
e.preventDefault()
|
||||
|
||||
try {
|
||||
let resp = await fetch(
|
||||
`/api-int/forms/${this.props.form.hashid}/whitelabel`,
|
||||
{
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
...this.defaultValues,
|
||||
...this.props.form.template,
|
||||
...this.state.changes
|
||||
}),
|
||||
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 custom template: ${r.error}`
|
||||
: 'Failed to save custom template.'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
toastr.success('Custom template saved.')
|
||||
this.props.onUpdate().then(() => {
|
||||
this.setState({changes: {}})
|
||||
})
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
toastr.error(
|
||||
'Failed to save custom template. See the console for more details.'
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,136 @@
|
|||
/** @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')
|
||||
const FormDescription = require('./FormDescription')
|
||||
|
||||
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>
|
||||
{this.state.form &&
|
||||
<FormDescription prefix="For " form={this.state.form} />
|
||||
}
|
||||
</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.`)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -2,10 +2,14 @@
|
|||
|
||||
const render = require('react-dom').render
|
||||
const React = require('react') // eslint-disable-line no-unused-vars
|
||||
const Modal = require('react-modal')
|
||||
|
||||
const Dashboard = require('./Dashboard')
|
||||
|
||||
if (document.querySelector('body.forms.dashboard')) {
|
||||
let el = document.querySelector('.container.block')
|
||||
Modal.setAppElement(el)
|
||||
|
||||
document.querySelector('.menu .item:nth-child(2)').innerHTML = ''
|
||||
render(<Dashboard />, document.querySelector('.container.block'))
|
||||
render(<Dashboard />, el)
|
||||
}
|
||||
|
|
|
@ -43,6 +43,7 @@ def configure_routes(app):
|
|||
app.add_url_rule('/forms/<hashid>', view_func=fv.serve_dashboard, methods=['GET'])
|
||||
app.add_url_rule('/forms/<hashid>/<path:s>', view_func=fv.serve_dashboard, methods=['GET'])
|
||||
app.add_url_rule('/forms/<hashid>.<format>', view_func=fv.export_submissions, methods=['GET'])
|
||||
app.add_url_rule('/forms/whitelabel/preview', view_func=fv.custom_template_preview_render, methods=['GET'])
|
||||
app.add_url_rule('/api-int/forms', view_func=fa.list, methods=['GET'])
|
||||
app.add_url_rule('/api-int/forms', view_func=fa.create, methods=['POST'])
|
||||
app.add_url_rule('/api-int/forms/<hashid>', view_func=fa.get, methods=['GET'])
|
||||
|
@ -50,6 +51,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/<hashid>/whitelabel', view_func=fa.custom_template_set, methods=['PUT'])
|
||||
|
||||
# Webhooks
|
||||
app.add_url_rule('/webhooks/stripe', view_func=uv.stripe_webhook, methods=['POST'])
|
||||
|
|
|
@ -123,9 +123,10 @@
|
|||
|
||||
.form-description {
|
||||
font-size: 20px;
|
||||
margin-top: -1em;
|
||||
}
|
||||
|
||||
.integration-tabs {
|
||||
.code-tabs {
|
||||
& > * {
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
|
@ -190,6 +191,13 @@
|
|||
overflow-x: auto;
|
||||
}
|
||||
|
||||
a.button.export {
|
||||
width: 11em;
|
||||
text-align: center;
|
||||
display: inline-block;
|
||||
margin: 0 0 20px 20px;
|
||||
}
|
||||
|
||||
table {
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
|
@ -375,14 +383,8 @@
|
|||
top: -100%;
|
||||
z-index: 11;
|
||||
width: 770px;
|
||||
-webkit-transform: translate(0, -500%);
|
||||
-ms-transform: translate(0, -500%);
|
||||
transform: translate(0, -500%);
|
||||
-webkit-transition: -webkit-transform 0.3s ease-out;
|
||||
-moz-transition: -moz-transform 0.3s ease-out;
|
||||
-o-transition: -o-transform 0.3s ease-out;
|
||||
transition: transform 0.3s ease-out;
|
||||
|
||||
transition: transform 0.5s ease-out;
|
||||
padding: 0.3em 1.3em 1.3em 1.3em;
|
||||
|
||||
.x a {
|
||||
|
@ -494,14 +496,74 @@
|
|||
}
|
||||
}
|
||||
|
||||
a.button.export {
|
||||
width: 11em;
|
||||
text-align: center;
|
||||
display: inline-block;
|
||||
margin: 0 0 20px 20px;
|
||||
#whitelabel {
|
||||
.container {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
#from_name::after {
|
||||
content: ' submissions@formspree.io';
|
||||
color: grey;
|
||||
}
|
||||
|
||||
.syntax_ref {
|
||||
position: relative;
|
||||
top: 2em;
|
||||
display: inline-block;
|
||||
|
||||
@media (max-width: 760px) {
|
||||
text-align: left;
|
||||
top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.right {
|
||||
@media (max-width: 760px) {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#whitelabel-preview-modal {
|
||||
.preview {
|
||||
padding: 12px;
|
||||
margin: 12px;
|
||||
border: 5px dotted #444;
|
||||
width: 700px;
|
||||
height: 600px;
|
||||
}
|
||||
}
|
||||
|
||||
.ReactModal__Overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(255, 255, 255, 0.5);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
.ReactModal__Content {
|
||||
z-index: 11;
|
||||
position: absolute;
|
||||
top: 80px;
|
||||
background: #fefefe;
|
||||
border: #333333 solid 1px;
|
||||
border-radius: 5px;
|
||||
padding: 1.4em 1.4em 0.5em 1.4em;
|
||||
max-width: 900px;
|
||||
transform: translate(0, -500%);
|
||||
transition: transform 0.6s ease-out;
|
||||
}
|
||||
|
||||
&.ReactModal__Overlay--after-open .ReactModal__Content {
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.CodeMirror {
|
||||
z-index: 0;
|
||||
padding: 10px;
|
||||
height: auto;
|
||||
font-size: 90%;
|
||||
|
|
|
@ -66,6 +66,7 @@ body#card {
|
|||
border-bottom: 1px solid #ddd;
|
||||
&#header {
|
||||
border-color: $green;
|
||||
padding: 4em 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -26,7 +26,9 @@
|
|||
}
|
||||
|
||||
.switch-row {
|
||||
margin: auto;
|
||||
// margin: auto;
|
||||
// carefully placed to work within formspree grid
|
||||
margin: 14px 0 0 -20px;
|
||||
position: relative;
|
||||
text-align: right;
|
||||
.switch {
|
||||
|
|
|
@ -67,6 +67,11 @@ a {
|
|||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.subtext {
|
||||
color: $gray;
|
||||
font-size: small;
|
||||
}
|
||||
|
||||
.h1-seo-hack {
|
||||
font-size: $h1_font_size;
|
||||
margin: $h1_margin;
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import os
|
||||
from premailer import transform
|
||||
from premailer import Premailer
|
||||
|
||||
TEMPLATES_DIR = 'formspree/templates/email/pre_inline_style/'
|
||||
|
||||
|
@ -8,7 +8,8 @@ def generate_templates():
|
|||
for filename in os.listdir(TEMPLATES_DIR):
|
||||
if filename.endswith('.html'):
|
||||
with open(os.path.join(TEMPLATES_DIR, filename), 'r') as html:
|
||||
transformed_template = transform(html.read())
|
||||
p = Premailer(html.read(), remove_classes=True)
|
||||
transformed_template = p.transform()
|
||||
|
||||
# weird issue with jinja templates beforehand so we use this hack
|
||||
# see https://github.com/peterbe/premailer/issues/72
|
||||
|
|
|
@ -64,7 +64,7 @@
|
|||
<div class="menu">
|
||||
{% if request.path != '/' %}<span class="item"><a href="/">Home</a></span>{% endif %}
|
||||
<span class="item"><a href="{{ url_for('dashboard') }}">Your forms</a></span>
|
||||
<span class="item"><a href="{{ url_for('account') }}">{% if current_user.upgraded %}Your{% else %}Upgrade{% endif %} account</a></span>
|
||||
<span class="item"><a href="{{ url_for('account') }}">{% if current_user.has_feature('dashboard') %}Your{% else %}Upgrade{% endif %} account</a></span>
|
||||
{% else %}
|
||||
<div class="greetings">
|
||||
<h4 class="light">
|
||||
|
|
|
@ -43,7 +43,7 @@
|
|||
<div class="card">
|
||||
<h3>Plan</h3>
|
||||
|
||||
{% if current_user.upgraded %}
|
||||
{% if current_user.has_feature('dashboard') %}
|
||||
<p>You are a {{ config.SERVICE_NAME }} {{ config.UPGRADED_PLAN_NAME }} user.</p>
|
||||
{% if sub.cancel_at_period_end %}
|
||||
<p>You've cancelled your subscription and it is ending on {{ sub.current_period_end }}.</p>
|
||||
|
|
|
@ -11,7 +11,7 @@
|
|||
<div class="card">
|
||||
<h3>Plan</h3>
|
||||
|
||||
{% if current_user.upgraded %}
|
||||
{% if current_user.has_feature('dashboard') %}
|
||||
<p>You are a {{ config.SERVICE_NAME }} {{ config.UPGRADED_PLAN_NAME }} user.</p>
|
||||
{% if sub.cancel_at_period_end %}
|
||||
<p>You've cancelled your subscription and it is ending on {{ sub.current_period_end }}.</p>
|
||||
|
|
|
@ -18,7 +18,7 @@
|
|||
|
||||
<div class="row section grey">
|
||||
<div class="container narrow block">
|
||||
{% if current_user.upgraded %}
|
||||
{% if current_user.has_feature('dashboard') %}
|
||||
<div class="col-1-2">
|
||||
<p>{{config.SERVICE_NAME}} is a tool <a href="https://github.com/formspree/formspree">managed on GitHub</a>. For fastest support check out our <a href="https://help.formspree.io">knowledge base</a> or use the form on the right.</p>
|
||||
</div>
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import hmac
|
||||
import hashlib
|
||||
from datetime import datetime
|
||||
|
||||
from flask import url_for, render_template, render_template_string, g
|
||||
|
||||
from formspree import settings
|
||||
|
@ -8,13 +9,30 @@ from formspree.stuff import DB, TEMPLATES
|
|||
from formspree.utils import send_email, IS_VALID_EMAIL
|
||||
from .helpers import hash_pwd
|
||||
|
||||
|
||||
class Plan(DB.Enum):
|
||||
free = 'v1_free'
|
||||
gold = 'v1_gold'
|
||||
platinum = 'v1_platinum'
|
||||
|
||||
plan_features = {
|
||||
'v1_free': set(),
|
||||
'v1_gold': {'dashboard', 'unlimited'},
|
||||
'v1_platinum': {'dashboard', 'unlimited', 'whitelabel'}
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def has_feature(cls, plan, feature_id):
|
||||
return feature_id in cls.plan_features[plan]
|
||||
|
||||
|
||||
class User(DB.Model):
|
||||
__tablename__ = 'users'
|
||||
|
||||
id = DB.Column(DB.Integer, primary_key=True)
|
||||
email = DB.Column(DB.Text, unique=True, index=True)
|
||||
password = DB.Column(DB.String(100))
|
||||
upgraded = DB.Column(DB.Boolean)
|
||||
plan = DB.Column(DB.Enum(*Plan.plan_features.keys(), name='plans'), nullable=False)
|
||||
stripe_id = DB.Column(DB.String(50))
|
||||
registered_on = DB.Column(DB.DateTime)
|
||||
invoice_address = DB.Column(DB.Text)
|
||||
|
@ -40,7 +58,7 @@ class User(DB.Model):
|
|||
|
||||
self.email = email
|
||||
self.password = hash_pwd(password)
|
||||
self.upgraded = False
|
||||
self.plan = Plan.free
|
||||
self.registered_on = datetime.utcnow()
|
||||
|
||||
@property
|
||||
|
@ -55,6 +73,13 @@ class User(DB.Model):
|
|||
def is_anonymous(self):
|
||||
return False
|
||||
|
||||
@property
|
||||
def features(self):
|
||||
return Plan.plan_features[self.plan]
|
||||
|
||||
def has_feature(self, feature_id):
|
||||
return Plan.has_feature(self.plan, feature_id)
|
||||
|
||||
def get_id(self):
|
||||
return self.id
|
||||
|
||||
|
|
|
@ -10,7 +10,7 @@ from sqlalchemy.exc import IntegrityError
|
|||
from formspree import settings
|
||||
from formspree.stuff import DB, TEMPLATES
|
||||
from formspree.utils import send_email
|
||||
from .models import User, Email
|
||||
from .models import User, Email, Plan
|
||||
from .helpers import check_password, hash_pwd, send_downgrade_email, \
|
||||
send_downgrade_reason_email
|
||||
|
||||
|
@ -231,7 +231,7 @@ def upgrade():
|
|||
"error")
|
||||
return redirect(url_for('dashboard'))
|
||||
|
||||
current_user.upgraded = True
|
||||
current_user.plan = Plan.gold
|
||||
DB.session.add(current_user)
|
||||
DB.session.commit()
|
||||
flash(u"Congratulations! You are now a {SERVICE_NAME} "
|
||||
|
@ -278,7 +278,8 @@ def downgrade():
|
|||
if reason:
|
||||
send_downgrade_reason_email.delay(current_user.email, reason)
|
||||
|
||||
sub = sub.delete(at_period_end=True)
|
||||
sub.cancel_at_period_end = True
|
||||
sub.save()
|
||||
flash(u"You were unregistered from the {SERVICE_NAME} "
|
||||
"{UPGRADED_PLAN_NAME} plan.".format(**settings.__dict__),
|
||||
'success')
|
||||
|
@ -310,7 +311,7 @@ def stripe_webhook():
|
|||
customer = stripe.Customer.retrieve(customer_id)
|
||||
if len(customer.subscriptions.data) == 0:
|
||||
user = User.query.filter_by(stripe_id=customer_id).first()
|
||||
user.upgraded = False
|
||||
user.plan = Plan.free
|
||||
DB.session.add(user)
|
||||
DB.session.commit()
|
||||
g.log.info('Downgraded user from webhook.', account=user.email)
|
||||
|
@ -320,8 +321,10 @@ def stripe_webhook():
|
|||
customer = stripe.Customer.retrieve(customer_id)
|
||||
g.log.info('User payment failed', account=customer.email)
|
||||
send_email(to=customer.email,
|
||||
subject='[ACTION REQUIRED] Failed Payment for {} {}'.format(settings.SERVICE_NAME,
|
||||
settings.UPGRADED_PLAN_NAME),
|
||||
subject='[ACTION REQUIRED] Failed Payment for {} {}'.format(
|
||||
settings.SERVICE_NAME,
|
||||
settings.UPGRADED_PLAN_NAME
|
||||
),
|
||||
text=render_template('email/payment-failed.txt'),
|
||||
html=render_template_string(TEMPLATES.get('payment-failed.html')),
|
||||
sender=settings.DEFAULT_SENDER)
|
||||
|
@ -433,6 +436,7 @@ def account():
|
|||
sub.current_period_end = datetime.datetime.fromtimestamp(sub.current_period_end).strftime('%A, %B %d, %Y')
|
||||
except stripe.error.StripeError:
|
||||
return render_template('error.html', title='Unable to connect', text="We're unable to make a secure connection to verify your account details. Please try again in a little bit. If this problem persists, please contact <strong>%s</strong>" % settings.CONTACT_EMAIL)
|
||||
|
||||
return render_template('users/account.html', emails=emails, cards=cards, sub=sub)
|
||||
|
||||
@login_required
|
||||
|
|
|
@ -118,7 +118,8 @@ def next_url(referrer=None, next=None):
|
|||
|
||||
|
||||
def send_email(to=None, subject=None, text=None, html=None,
|
||||
sender=None, cc=None, reply_to=None, headers=None):
|
||||
sender=None, cc=None, reply_to=None, headers=None,
|
||||
from_name=None):
|
||||
g.log = g.log.new(to=to, sender=sender)
|
||||
|
||||
if None in [to, subject, text, sender]:
|
||||
|
@ -131,17 +132,20 @@ def send_email(to=None, subject=None, text=None, html=None,
|
|||
'text': text,
|
||||
'html': html}
|
||||
|
||||
# parse 'fromname' from 'sender' if it is
|
||||
# parse 'from_name' from 'sender' if it is
|
||||
# formatted like "Name <name@email.com>"
|
||||
try:
|
||||
bracket = sender.index('<')
|
||||
data.update({
|
||||
'from': sender[bracket+1:-1],
|
||||
'fromname': sender[:bracket].strip()
|
||||
'from_name': sender[:bracket].strip()
|
||||
})
|
||||
except ValueError:
|
||||
data.update({'from': sender})
|
||||
|
||||
if from_name:
|
||||
data.update({'from_name': from_name})
|
||||
|
||||
if headers:
|
||||
data.update({'headers': json.dumps(headers)})
|
||||
|
||||
|
|
|
@ -0,0 +1,54 @@
|
|||
"""email templates and plan enum
|
||||
|
||||
Revision ID: 7446b8bbc888
|
||||
Revises: a156683c29f2
|
||||
Create Date: 2018-09-19 22:38:57.679264
|
||||
|
||||
"""
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '7446b8bbc888'
|
||||
down_revision = 'a156683c29f2'
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
from sqlalchemy.dialects.postgresql import ENUM
|
||||
|
||||
plans = ENUM('v1_free', 'v1_gold', 'v1_platinum', name='plans', create_type=False)
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('email_templates',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('form_id', sa.Integer(), nullable=False),
|
||||
sa.Column('subject', sa.Text(), nullable=False),
|
||||
sa.Column('from_name', sa.Text(), nullable=False),
|
||||
sa.Column('style', sa.Text(), nullable=False),
|
||||
sa.Column('body', sa.Text(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['form_id'], ['forms.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('form_id')
|
||||
)
|
||||
op.alter_column('submissions', 'form_id',
|
||||
existing_type=sa.INTEGER(),
|
||||
nullable=False)
|
||||
plans.create(op.get_bind(), checkfirst=True)
|
||||
op.add_column('users', sa.Column('plan', plans, nullable=True))
|
||||
op.execute("UPDATE users SET plan = 'v1_gold' WHERE upgraded")
|
||||
op.execute("UPDATE users SET plan = 'v1_free' WHERE NOT upgraded")
|
||||
op.drop_column('users', 'upgraded')
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('users', sa.Column('upgraded', sa.BOOLEAN(), autoincrement=False, nullable=True))
|
||||
op.execute("UPDATE users SET upgraded = true WHERE plan != 'v1_free'")
|
||||
op.execute("UPDATE users SET upgraded = false WHERE plan = 'v1_free'")
|
||||
op.drop_column('users', 'plan')
|
||||
op.alter_column('submissions', 'form_id',
|
||||
existing_type=sa.INTEGER(),
|
||||
nullable=True)
|
||||
op.drop_table('email_templates')
|
||||
# ### end Alembic commands ###
|
|
@ -3,9 +3,11 @@
|
|||
"class-set": "^0.0.4",
|
||||
"codemirror": "^5.39.2",
|
||||
"is-valid-email": "0.0.2",
|
||||
"query-string": "^6.1.0",
|
||||
"react": "^16.4.2",
|
||||
"react-codemirror2": "^5.1.0",
|
||||
"react-dom": "^16.4.2",
|
||||
"react-modal": "^3.5.1",
|
||||
"react-router-dom": "^4.3.1",
|
||||
"url": "^0.11.0",
|
||||
"valid-url": "^1.0.9"
|
||||
|
|
|
@ -3,7 +3,7 @@ import json
|
|||
from formspree import settings
|
||||
from formspree.stuff import DB
|
||||
from formspree.forms.helpers import HASH
|
||||
from formspree.users.models import User
|
||||
from formspree.users.models import User, Plan
|
||||
from formspree.forms.models import Form, Submission
|
||||
|
||||
def test_automatically_created_forms(client, msend):
|
||||
|
@ -147,7 +147,7 @@ def test_automatically_created_forms(client, msend):
|
|||
assert newest.data['name'] == 'husserl'
|
||||
assert last.data['name'] == 'schelling'
|
||||
|
||||
def test_upgraded_user_access(client, msend):
|
||||
def test_gold_user_access(client, msend):
|
||||
# register user
|
||||
r = client.post('/register',
|
||||
data={'email': 'colorado@springs.com',
|
||||
|
@ -156,7 +156,7 @@ def test_upgraded_user_access(client, msend):
|
|||
|
||||
# upgrade user manually
|
||||
user = User.query.filter_by(email='colorado@springs.com').first()
|
||||
user.upgraded = True
|
||||
user.plan = Plan.gold
|
||||
DB.session.add(user)
|
||||
DB.session.commit()
|
||||
|
||||
|
@ -209,7 +209,7 @@ def test_upgraded_user_access(client, msend):
|
|||
assert '"hi in my name is bruce!"', lines[1]
|
||||
|
||||
# test submissions endpoint with the user downgraded
|
||||
user.upgraded = False
|
||||
user.plan = Plan.free
|
||||
DB.session.add(user)
|
||||
DB.session.commit()
|
||||
r = client.get("/api-int/forms/" + form_endpoint)
|
||||
|
|
|
@ -2,7 +2,7 @@ import json
|
|||
|
||||
from formspree import settings
|
||||
from formspree.stuff import DB
|
||||
from formspree.users.models import User, Email
|
||||
from formspree.users.models import User, Email, Plan
|
||||
from formspree.forms.models import Form
|
||||
|
||||
from .conftest import parse_confirmation_link_sent
|
||||
|
@ -61,7 +61,7 @@ def test_user_gets_previous_forms_assigned_to_him(client, msend):
|
|||
link, qs = parse_confirmation_link_sent(msend.call_args[1]['text'])
|
||||
client.get(link, query_string=qs)
|
||||
|
||||
# confirm that the user has no access to the form since he is not upgraded
|
||||
# confirm that the user has no access to the form since he is not gold
|
||||
r = client.get(
|
||||
"/api-int/forms",
|
||||
headers={"Accept": "application/json", "Referer": settings.SERVICE_URL},
|
||||
|
@ -71,7 +71,7 @@ def test_user_gets_previous_forms_assigned_to_him(client, msend):
|
|||
|
||||
# upgrade user
|
||||
user = User.query.filter_by(email=u'márkö@example.com').first()
|
||||
user.upgraded = True
|
||||
user.plan = Plan.gold
|
||||
DB.session.add(user)
|
||||
DB.session.commit()
|
||||
|
||||
|
|
|
@ -3,7 +3,7 @@ import json
|
|||
from formspree import settings
|
||||
from formspree.stuff import DB
|
||||
from formspree.forms.helpers import HASH
|
||||
from formspree.users.models import User, Email
|
||||
from formspree.users.models import User, Email, Plan
|
||||
from formspree.forms.models import Form, Submission
|
||||
|
||||
def test_form_creation(client, msend):
|
||||
|
@ -27,7 +27,7 @@ def test_form_creation(client, msend):
|
|||
|
||||
# upgrade user manually
|
||||
user = User.query.filter_by(email='colorado@springs.com').first()
|
||||
user.upgraded = True
|
||||
user.plan = Plan.gold
|
||||
DB.session.add(user)
|
||||
DB.session.commit()
|
||||
|
||||
|
@ -67,7 +67,7 @@ def test_form_creation(client, msend):
|
|||
# Make sure that it marks the first form as AJAX
|
||||
assert Form.query.first().uses_ajax
|
||||
|
||||
# send 5 forms (monthly limits should not apply to the upgraded user)
|
||||
# send 5 forms (monthly limits should not apply to the gold user)
|
||||
assert settings.MONTHLY_SUBMISSIONS_LIMIT == 2
|
||||
for i in range(5):
|
||||
r = client.post(
|
||||
|
@ -98,7 +98,7 @@ def test_form_creation_with_a_registered_email(client, msend):
|
|||
)
|
||||
# upgrade user manually
|
||||
user = User.query.filter_by(email="user@testsite.com").first()
|
||||
user.upgraded = True
|
||||
user.plan = Plan.gold
|
||||
DB.session.add(user)
|
||||
DB.session.commit()
|
||||
|
||||
|
@ -180,7 +180,7 @@ def test_sitewide_forms(client, msend, mocker):
|
|||
)
|
||||
# upgrade user manually
|
||||
user = User.query.filter_by(email="user@testsite.com").first()
|
||||
user.upgraded = True
|
||||
user.plan = Plan.gold
|
||||
DB.session.add(user)
|
||||
DB.session.commit()
|
||||
|
||||
|
@ -330,7 +330,7 @@ def test_form_settings(client, msend):
|
|||
# register and upgrade user
|
||||
client.post("/register", data={"email": "texas@springs.com", "password": "water"})
|
||||
user = User.query.filter_by(email="texas@springs.com").first()
|
||||
user.upgraded = True
|
||||
user.plan = Plan.gold
|
||||
DB.session.add(user)
|
||||
DB.session.commit()
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
from formspree import settings
|
||||
from formspree.stuff import DB
|
||||
from formspree.forms.models import Form
|
||||
from formspree.users.models import User, Email
|
||||
from formspree.users.models import User, Email, Plan
|
||||
|
||||
http_headers = {
|
||||
'Referer': 'testwebsite.com'
|
||||
|
@ -233,13 +233,13 @@ def test_monthly_limits(client, msend):
|
|||
assert f.counter == 3
|
||||
assert f.get_monthly_counter() == 3 # the counters mark 4
|
||||
|
||||
# the user pays and becomes upgraded
|
||||
# the user pays and becomes gold
|
||||
r = client.post('/register',
|
||||
data={'email': 'luke@testwebsite.com',
|
||||
'password': 'banana'}
|
||||
)
|
||||
user = User.query.filter_by(email='luke@testwebsite.com').first()
|
||||
user.upgraded = True
|
||||
user.plan = Plan.gold
|
||||
user.emails = [Email(address='luke@testwebsite.com')]
|
||||
DB.session.add(user)
|
||||
DB.session.commit()
|
||||
|
|
|
@ -5,7 +5,7 @@ import stripe
|
|||
from formspree import settings
|
||||
from formspree.stuff import DB
|
||||
from formspree.forms.helpers import HASH
|
||||
from formspree.users.models import User, Email
|
||||
from formspree.users.models import User, Email, Plan
|
||||
from formspree.forms.models import Form, Submission
|
||||
|
||||
from .conftest import parse_confirmation_link_sent
|
||||
|
@ -123,7 +123,7 @@ def test_form_creation(client, msend):
|
|||
|
||||
# upgrade user manually
|
||||
user = User.query.filter_by(email='colorado@springs.com').first()
|
||||
user.upgraded = True
|
||||
user.plan = Plan.gold
|
||||
DB.session.add(user)
|
||||
DB.session.commit()
|
||||
|
||||
|
@ -156,7 +156,7 @@ def test_form_creation(client, msend):
|
|||
client.get('/confirm/%s:%s' % (HASH(form.email, str(form.id)), form.hashid))
|
||||
assert Form.query.first().confirmed
|
||||
|
||||
# send 5 forms (monthly limits should not apply to the upgraded user)
|
||||
# send 5 forms (monthly limits should not apply to the gold user)
|
||||
assert settings.MONTHLY_SUBMISSIONS_LIMIT == 2
|
||||
for i in range(5):
|
||||
r = client.post('/' + form_endpoint,
|
||||
|
@ -192,7 +192,7 @@ def test_form_toggle(client, msend):
|
|||
|
||||
# upgrade user
|
||||
user = User.query.filter_by(email='hello@world.com').first()
|
||||
user.upgraded = True
|
||||
user.plan = Plan.gold
|
||||
DB.session.add(user)
|
||||
DB.session.commit()
|
||||
|
||||
|
@ -281,7 +281,7 @@ def test_form_and_submission_deletion(client, msend):
|
|||
|
||||
# upgrade user
|
||||
user = User.query.filter_by(email='hello@world.com').first()
|
||||
user.upgraded = True
|
||||
user.plan = Plan.gold
|
||||
DB.session.add(user)
|
||||
DB.session.commit()
|
||||
|
||||
|
@ -399,7 +399,7 @@ def test_user_upgrade_and_downgrade(client, msend, mocker):
|
|||
msend.reset_mock()
|
||||
|
||||
user = User.query.filter_by(email='maria@example.com').first()
|
||||
assert user.upgraded == False
|
||||
assert user.plan == Plan.free
|
||||
|
||||
# subscribe with card through stripe
|
||||
token = stripe.Token.create(card={
|
||||
|
@ -414,7 +414,7 @@ def test_user_upgrade_and_downgrade(client, msend, mocker):
|
|||
})
|
||||
|
||||
user = User.query.filter_by(email='maria@example.com').first()
|
||||
assert user.upgraded == True
|
||||
assert user.plan == Plan.gold
|
||||
|
||||
# downgrade back to the free plan
|
||||
r = client.post('/account/downgrade', follow_redirects=True)
|
||||
|
@ -424,7 +424,7 @@ def test_user_upgrade_and_downgrade(client, msend, mocker):
|
|||
assert "You've cancelled your subscription and it is ending on" in r.data.decode('utf-8')
|
||||
|
||||
user = User.query.filter_by(email='maria@example.com').first()
|
||||
assert user.upgraded == True
|
||||
assert user.plan == Plan.gold
|
||||
|
||||
customer = stripe.Customer.retrieve(user.stripe_id)
|
||||
assert customer.subscriptions.data[0].cancel_at_period_end == True
|
||||
|
@ -445,7 +445,7 @@ def test_user_upgrade_and_downgrade(client, msend, mocker):
|
|||
}), headers={'Content-type': 'application/json'})
|
||||
|
||||
user = User.query.filter_by(email='maria@example.com').first()
|
||||
assert user.upgraded == False
|
||||
assert user.plan == Plan.free
|
||||
assert m_senddowngraded.called
|
||||
|
||||
# delete the stripe customer
|
||||
|
@ -465,7 +465,7 @@ def test_user_card_management(client, msend):
|
|||
assert r.status_code == 302
|
||||
|
||||
user = User.query.filter_by(email='maria@example.com').first()
|
||||
assert user.upgraded == False
|
||||
assert user.plan == Plan.free
|
||||
|
||||
# subscribe with card through stripe
|
||||
token = stripe.Token.create(card={
|
||||
|
@ -479,7 +479,7 @@ def test_user_card_management(client, msend):
|
|||
})
|
||||
|
||||
user = User.query.filter_by(email='maria@example.com').first()
|
||||
assert user.upgraded == True
|
||||
assert user.plan == Plan.gold
|
||||
|
||||
# add another card
|
||||
token = stripe.Token.create(card={
|
||||
|
|
Loading…
Reference in New Issue